diff --git a/.github/scripts/fixtures/silent-revert/886-cf907be7.json b/.github/scripts/fixtures/silent-revert/886-cf907be7.json new file mode 100644 index 000000000..569740caa --- /dev/null +++ b/.github/scripts/fixtures/silent-revert/886-cf907be7.json @@ -0,0 +1,920 @@ +{ + "case": "886-cf907be7", + "note": "Recorded from the real trees on 2026-10-24: base 690093bc (the merge base with main), main 79aa6128, head cf907be7. The docs/subsystems/social.md finding is trimmed; the real run also reported it.", + "pr": 886, + "head": "cf907be72f7bde77ba280fa3f56be32d5755b49f", + "base": "690093bc7b8f22945efc0050ac4ca44b438f6f8f", + "main": "79aa6128b6e0ef013e41c07f44815754c4e4684a", + "scanned": 3, + "files": [ + { + "path": "plugins/social/src/social.js", + "status": "modified", + "addedByMain": [ + " // A seat a room link admitted is never a friend's Bot (#884): the roster does not list it, and", + " // its link, not the roster, decides when it goes.", + " if (p.admittedBy === 'ticket') continue", + " // #883: a room share to a seat a link opened (or, from a guest, to the room's inviter) is the", + " // room's own traffic: it passes, under that room's scope only. Anything else to someone off the", + " // roster — a plain line, another Bot, another room — keeps the refusal below.", + " if (ticketPair(msg)) return undefined", + " // A line that named its room and was refused because the room no longer covers it (#881): the", + " // room's own sentence, so the person does not read \"you are not friends\" about a room they are in.", + " if (/room_not_shared/i.test(blob)) {", + " return new Error('that room is closed between these Bots now. Send again from the room, or join it again.')", + " }", + " if (error?.roomScoped && /no_relationship|no active relationship/i.test(blob)) {", + " return new Error('the room no longer lets these Bots reach each other. Join the room together again.')", + " // #883: the one pair a room link opened for this send, if it is one: a room share (`via` room or", + " // room-share, never a plain line), from the exact local Bot, to the exact handle, under the room id", + " // the link names (`meta.scope`). The pairs are the host's (flybot.rooms.ticketPeers), not the text's.", + " function ticketPair(msg) {", + " if (msg.via !== 'room' && msg.via !== 'room-share') return undefined", + " const scope = typeof msg.meta?.scope === 'string' ? msg.meta.scope : ''", + " if (!scope || typeof msg.from !== 'string' || msg.attachments?.length) return undefined", + " // The text must be the host's own envelope for that same room, whole: a plain line wearing the", + " // `via` and the scope is not room traffic.", + " const share = roomShareEnvelope(msg.text)", + " if (!share || String(share.roomId ?? '') !== scope) return undefined", + " const to = norm(msg.target?.handle)", + " return (flybot.rooms?.ticketPeers?.() ?? []).find(peer => peer.from === msg.from && peer.scope === scope && norm(peer.to) === to)", + " }", + " // `FLYBOT-ROOM-SHARE\\n{json}` exactly as core's encodeRoomShare writes it (src/shared/room-share.js).", + " function roomShareEnvelope(text) {", + " const raw = String(text ?? '')", + " const head = 'FLYBOT-ROOM-SHARE\\n'", + " if (!raw.startsWith(head)) return undefined", + " try {", + " const data = JSON.parse(raw.slice(head.length))", + " return data && data.v === 1 && ['join', 'leave', 'line', 'ask', 'invite'].includes(data.op) ? data : undefined", + " } catch {", + " return undefined", + " // Sent under the room's id: Bot Social's room reach (the claim's grant pair) carries it, and refuses", + " // it once the link is withdrawn or given back.", + " async function sendTicketShare(msg, pair) {", + " if (!home) throw new Error('Bot Social is not connected on this machine yet')", + " const member = activeMember(msg.from)", + " if (!member) throw new Error(`${flybot.bot.get(msg.from)?.name ?? 'This Bot'} has no Bot Social identity yet`)", + " const to = norm(msg.target.handle)", + " const sent = await client.callTool('send_as_member', { from_bot_id: member.bot_id, to, text: msg.text, room_id: pair.scope })", + " .catch((error) => { if (isPaused(error)) notePaused(to, error); throw error })", + " clearPaused(to)", + " if (sent?.message?.status === 'pending_approval') await autoSendRoomEnvelope(sent)", + " return { exchangeId: sent?.message?.message_id ?? msg.exchangeId }", + "", + " const pair = ticketPair(msg)", + " if (pair) return sendTicketShare(msg, pair)", + " // A 1:1 line between two Bots that are in one open room here names that room (#881): the", + " // room is the reach the server can check, and it is the consent that lifts its own hold. Room", + " // lines themselves are the group's record and name no room: a local room has no id on the", + " // server, so naming one would be a lie it refuses.", + " const roomId = msg.via === 'room' || msg.via === 'room-share' ? undefined : roomContextFor(msg.from, bot.handle)", + " const sent = await client.callTool('send_as_member', { from_bot_id: member.bot_id, to: bot.handle, text: msg.text, ...(attachments.length ? { attachments } : {}), ...(roomId ? { room_id: roomId } : {}) })", + " .catch((error) => {", + " if (isPaused(error)) notePaused(bot.handle, error)", + " // The refusal a room-scoped send earns needs a sentence of its own, so the mapping can", + " // tell \"these Bots share no room any more\" from \"you are not friends with this Bot\".", + " if (roomId && error && typeof error === 'object') error.roomScoped = true", + " throw error", + " })", + " const typed = { supported: false, forbidden: false, mentions: false, presence: false, roles: false, invites: false, localInvites: false, probedAt: 0, probeFailed: false, probing: undefined }", + " if (features?.includes('local_room_invites')) typed.localInvites = true", + " // Whether this server keeps a local room's invite tickets (Bot Social #60): the same probe. An older", + " // server, or one that has not answered, gets the friend-only link it had.", + " async function serverLocalInvites() {", + " if (typed.localInvites || !faceApi?.features) return typed.localInvites", + " if (typed.probing) await typed.probing", + " else if (!typed.probedAt || now() - typed.probedAt >= (typed.probeFailed ? TYPED_RETRY_MS : TYPED_RECHECK_MS)) await (typed.probing = probeTypedSupport())", + " return typed.localInvites", + " // The owner's own line names the room too when both Bots are in it (#881): same rule as the", + " // transport, so a typed line and a Bot line reach each other by the same reach.", + " const roomId = roomContextFor(bot.id, target)", + " const sent = await sendTypedAsMember({ from_bot_id: member.bot_id, to: target, text, ...(roomId ? { room_id: roomId } : {}) })", + " // A room-scoped refusal is the room's answer, not this member's: another Bot in the same home", + " // would name the same room and hear the same thing, so it is reported instead of retried.", + " if (roomId && error && typeof error === 'object') error.roomScoped = true", + " if (roomId || !/no_relationship|no active relationship/i.test(String(error?.message ?? error))) throw userFacingSendError(error, target)", + " // The room a 1:1 line may name (#881, Bot Social #43/#50): this Bot and the friend's Bot are both", + " // live in one face room this home has not left, so Bot Social holds a room-scoped grant for it. It", + " // is the only room id the platform can check at all: a local room (#852) has no face_rooms row,", + " // so naming one there would only earn `room_not_shared`. A room that is left, closed or gone from", + " // `settings` stops answering, and the next send is back to the plain per-grant rules.", + " function roomContextFor(fromBotId, handle) {", + " const target = norm(handle)", + " if (!target || !fromBotId || typeof flybot.rooms?.ofFace !== 'function') return undefined", + " const mine = new Set([norm(memberOf(fromBotId)?.handle)].filter(Boolean))", + " for (const faceId of Object.keys(settings.faceRooms ?? {})) {", + " const room = flybot.rooms.ofFace(faceId)", + " if (!room) continue", + " const status = room.face?.status", + " if (status === 'closed' || status === 'left') continue", + " const live = (room.participants ?? []).filter(participant => participant.leftSeq === undefined)", + " const iAmIn = live.some(participant => participant.id === fromBotId", + " || (participant.remote !== true && mine.has(norm(participant.handle))))", + " if (!iAmIn || mine.has(target)) continue", + " if (live.some(participant => participant.remote === true && norm(participant.handle) === target)) return faceId", + " return undefined", + " // #884: Bot Social's record of a local room's invite tickets, behind the host's link list and its", + " // seats. A server without `local_room_invites` mints nothing here, so the host keeps its friend-only", + " // link; verify is the same read with this home's token, which names the claimer to the minter only.", + " if (faceApi?.localInvites && typeof flybot.rooms?.serveInvites === 'function') {", + " disposers.push(flybot.rooms.serveInvites({", + " // A link's own Bots get a full identity (polled, so the room's lines arrive), like a Bot invited", + " // to a friend: the owner's click on the link is that consent. A room-only one is promoted.", + " identify: async (ids) => {", + " for (const id of ids) {", + " const bot = flybot.bot.get(id)", + " if (bot) await register(bot, { forInvite: true })", + " }", + " },", + " mint: async spec => ((await serverLocalInvites()) ? faceApi.localInvites.mint(spec) : undefined),", + " preview: ticket => faceApi.localInvites.preview(ticket),", + " claim: (ticket, handles) => faceApi.localInvites.claim(ticket, handles),", + " verify: ticket => faceApi.localInvites.preview(ticket),", + " revoke: (ticket, opts) => faceApi.localInvites.revoke(ticket, opts),", + " endClaim: (ticket, home) => faceApi.localInvites.endClaim(ticket, home),", + " }))" + ], + "deletedByHead": [ + "// The one hold the outbound paths may clear by itself (#737, #766, #865). Bot Social says why it held a", + "// line in `hold_reason`, and `blanket_outbound` — its own \"all outbound needs approval\" rule — is the", + "// only value they clear. Every other value is a finding or an owner setting (`risk`, `attachment`,", + "// `policy`, the face-room Auto-send switch): the line waits for the owner on its card. The field wins", + "// when it and `reasons` disagree.", + "// A server that sends no field keeps the old allow-list, so a reason this build does not know, another", + "// DLP category or a risk flag still holds the line (#766); an absent field is not \"not blanket\", or", + "// every hold on an older server would strand.", + " const reason = typeof item?.hold_reason === 'string' ? item.hold_reason : ''", + " if (reason) return reason === 'blanket_outbound'", + " return reasons.length > 0 && reasons.every(entry => String(entry) === BLANKET_HOLD) && flags.length === 0", + "}", + "// #457: the face-room path clears one more hold than the blanket rule. Bot Social writes", + "// `face_room_auto_send_off` for a face-room line when nothing but the room's own Auto-send switch is", + "// holding it, and that switch is the owner's standing consent (the owner pressed it for this Bot), so a", + "// Bot whose switch is on has to be able to send. Only `autoSendFaceLine` reads this; `autoSendRemoteLine`", + "// and `autoSendRoomEnvelope` stay on `holdIsBlanket` alone, so #865's strict rule for outbound lines is", + "// untouched. The field still decides: a row with no `hold_reason` is never cleared here, or an older", + "// server's holds would strand.", + "const FACE_ROOM_AUTO_SEND_OFF = 'face_room_auto_send_off'", + "export function holdAllowsFaceAutoSend(item) {", + " if (holdIsBlanket(item)) return true", + " return typeof item?.hold_reason === 'string' && item.hold_reason === FACE_ROOM_AUTO_SEND_OFF", + " // Why Bot Social says it held this line (#865): kept on the view so the decision and anything that", + " // shows it read one field, not a second inference from `reasons`.", + " ...(typeof item.hold_reason === 'string' && item.hold_reason ? { holdReason: item.hold_reason } : {}),", + " // What Auto-send may clear on its own (#766, #865): the blanket hold only. The face-room path reads", + " // `autoSendHold` beside it, which adds its own switch (#457); the outbound paths never do.", + " autoSendHold: holdAllowsFaceAutoSend(item),", + " // A seat a room link admitted is never a friend's Bot (#884): the roster does not list it, and", + " // its link, not the roster, decides when it goes.", + " if (p.admittedBy === 'ticket') continue", + " // #883: a room share to a seat a link opened (or, from a guest, to the room's inviter) is the", + " // room's own traffic: it passes, under that room's scope only. Anything else to someone off the", + " // roster — a plain line, another Bot, another room — keeps the refusal below.", + " if (ticketPair(msg)) return undefined", + " // A line that named its room and was refused because the room no longer covers it (#881): the", + " // #883: the one pair a room link opened for this send, if it is one: a room share (`via` room or", + " // room-share, never a plain line), from the exact local Bot, to the exact handle, under the room id", + " // the link names (`meta.scope`). The pairs are the host's (flybot.rooms.ticketPeers), not the text's.", + " function ticketPair(msg) {", + " if (msg.via !== 'room' && msg.via !== 'room-share') return undefined", + " const scope = typeof msg.meta?.scope === 'string' ? msg.meta.scope : ''", + " if (!scope || typeof msg.from !== 'string' || msg.attachments?.length) return undefined", + " // The text must be the host's own envelope for that same room, whole: a plain line wearing the", + " // `via` and the scope is not room traffic.", + " const share = roomShareEnvelope(msg.text)", + " if (!share || String(share.roomId ?? '') !== scope) return undefined", + " const to = norm(msg.target?.handle)", + " return (flybot.rooms?.ticketPeers?.() ?? []).find(peer => peer.from === msg.from && peer.scope === scope && norm(peer.to) === to)", + " }", + " // `FLYBOT-ROOM-SHARE\\n{json}` exactly as core's encodeRoomShare writes it (src/shared/room-share.js).", + " function roomShareEnvelope(text) {", + " const raw = String(text ?? '')", + " const head = 'FLYBOT-ROOM-SHARE\\n'", + " if (!raw.startsWith(head)) return undefined", + " try {", + " const data = JSON.parse(raw.slice(head.length))", + " return data && data.v === 1 && ['join', 'leave', 'line', 'ask', 'invite'].includes(data.op) ? data : undefined", + " } catch {", + " return undefined", + " }", + " // Sent under the room's id: Bot Social's room reach (the claim's grant pair) carries it, and refuses", + " // it once the link is withdrawn or given back.", + " async function sendTicketShare(msg, pair) {", + " if (!home) throw new Error('Bot Social is not connected on this machine yet')", + " const member = activeMember(msg.from)", + " if (!member) throw new Error(`${flybot.bot.get(msg.from)?.name ?? 'This Bot'} has no Bot Social identity yet`)", + " const to = norm(msg.target.handle)", + " const sent = await client.callTool('send_as_member', { from_bot_id: member.bot_id, to, text: msg.text, room_id: pair.scope })", + " .catch((error) => { if (isPaused(error)) notePaused(to, error); throw error })", + " clearPaused(to)", + " if (sent?.message?.status === 'pending_approval') await autoSendRoomEnvelope(sent)", + " return { exchangeId: sent?.message?.message_id ?? msg.exchangeId }", + "", + " const pair = ticketPair(msg)", + " if (pair) return sendTicketShare(msg, pair)", + " // A 1:1 line between two Bots that are in one open room here names that room (#881): the", + " // room is the reach the server can check, and it is the consent that lifts its own hold. Room", + " // lines themselves are the group's record and name no room: a local room has no id on the", + " // server, so naming one would be a lie it refuses.", + " const typed = { supported: false, forbidden: false, mentions: false, presence: false, roles: false, invites: false, localInvites: false, probedAt: 0, probeFailed: false, probing: undefined }", + " if (features?.includes('local_room_invites')) typed.localInvites = true", + " // Whether this server keeps a local room's invite tickets (Bot Social #60): the same probe. An older", + " // server, or one that has not answered, gets the friend-only link it had.", + " async function serverLocalInvites() {", + " if (typed.localInvites || !faceApi?.features) return typed.localInvites", + " if (typed.probing) await typed.probing", + " else if (!typed.probedAt || now() - typed.probedAt >= (typed.probeFailed ? TYPED_RETRY_MS : TYPED_RECHECK_MS)) await (typed.probing = probeTypedSupport())", + " return typed.localInvites", + " // The owner's own line names the room too when both Bots are in it (#881): same rule as the", + " // The room a 1:1 line may name (#881, Bot Social #43/#50): this Bot and the friend's Bot are both", + " // live in one face room this home has not left, so Bot Social holds a room-scoped grant for it. It", + " // is the only room id the platform can check at all: a local room (#852) has no face_rooms row,", + " // so naming one there would only earn `room_not_shared`. A room that is left, closed or gone from", + " // `settings` stops answering, and the next send is back to the plain per-grant rules.", + " if (!target || !fromBotId || typeof flybot.rooms?.ofFace !== 'function') return undefined", + " for (const faceId of Object.keys(settings.faceRooms ?? {})) {", + " const room = flybot.rooms.ofFace(faceId)", + " if (live.some(participant => participant.remote === true && norm(participant.handle) === target)) return faceId", + " // #884: Bot Social's record of a local room's invite tickets, behind the host's link list and its", + " // seats. A server without `local_room_invites` mints nothing here, so the host keeps its friend-only", + " // link; verify is the same read with this home's token, which names the claimer to the minter only.", + " if (faceApi?.localInvites && typeof flybot.rooms?.serveInvites === 'function') {", + " disposers.push(flybot.rooms.serveInvites({", + " // A link's own Bots get a full identity (polled, so the room's lines arrive), like a Bot invited", + " // to a friend: the owner's click on the link is that consent. A room-only one is promoted.", + " identify: async (ids) => {", + " for (const id of ids) {", + " const bot = flybot.bot.get(id)", + " if (bot) await register(bot, { forInvite: true })", + " }", + " },", + " mint: async spec => ((await serverLocalInvites()) ? faceApi.localInvites.mint(spec) : undefined),", + " preview: ticket => faceApi.localInvites.preview(ticket),", + " claim: (ticket, handles) => faceApi.localInvites.claim(ticket, handles),", + " verify: ticket => faceApi.localInvites.preview(ticket),", + " revoke: (ticket, opts) => faceApi.localInvites.revoke(ticket, opts),", + " endClaim: (ticket, home) => faceApi.localInvites.endClaim(ticket, home),", + " }))", + " // Approves a held room line of a Bot whose Auto-send is on (#457), only when Bot Social held it for no", + " // finding of its own — the blanket rule, or nothing but the room's own Auto-send switch", + " // (`autoSendHold`); anything else stays on its card.", + " if (!held || held.autoSendHold !== true) return false" + ] + }, + { + "path": "plugins/social/test/social.test.js", + "status": "modified", + "addedByMain": [ + "import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from '../../../core/src/client/room-invite-preview.js'", + "async function team({ rooms = {} } = {}) {", + " rooms, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0,", + " assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.approvals#social-held-reply', 'flybot.settings.page#social', 'flybot.room.overview#social-face', 'flybot.room.members#social-room-invite', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval', 'flybot.session.dock#social-thinking', 'flybot.session.dock#social-paused'])", + " // The invite card is core's (#876); the plugin only calls it, exactly as ds-bot's bundle registers it.", + " sandbox.__flybot__.RoomInvitePreview = RoomInvitePreview", + " sandbox.__flybot__.invitePreviewOf = invitePreviewOf", + " sandbox.__flybot__.inviteCountdown = inviteCountdown", + " // #892: one entry for both ways in, a number entered together or a group's invite link.", + " assert.equal(row[1].label, 'Join or create by number or link')", + " assert.match(row[1].hint, /number/)", + " assert.match(row[1].hint, /invite link/)", + "test('#876: the invite preview markup lives in the core card, not a second copy here', async () => {", + " // The paste flow and the member-invite dialog draw one tree of elements (#876). This file only", + " // hands the card its values, so none of the card's own hooks may exist here any more.", + " for (const hook of ['face-link-preview-name', 'face-link-preview-lead', 'face-link-preview-members', 'face-link-preview-expiry', 'face-link-preview-join', 'face-link-preview-clear', 'face-link-preview-foot']) {", + " assert.equal(source.includes(`'${hook}'`), false, `${hook} is markup, and it belongs to the core card`)", + " assert.match(source, /RoomInvitePreview/, 'the paste flow still renders the shared card')", + " // And the card the plugin gets is the one core exports, not a local stand-in.", + " const tree = walk(pluginOf([]).FaceToFaceGroup ? [] : [])", + " assert.ok(tree.length >= 0)", + "// ---- #876: a group link of another FlyBot, in the same paste field and scan -----------------", + "const LOCAL_URL = 'https://bot-social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv'", + "// The Face-to-face dialog with a typed local link: `host` answers core endpoints; every setInvite is kept.", + "function localLinkDialog(hostImpl, invite = { link: LOCAL_URL, status: null }) {", + " const hostCalls = []", + " const invites = []", + " let current = invite", + " const bots = [{ id: 'a', name: 'Chief', isMain: true }, { id: 'b', name: 'Scout' }]", + " const states = ['setup', bots, ['a', 'b'], '', false, null, [], null, 0, null]", + " const { registered, walk, sandbox } = loadSocialClient(async (endpoint, payload) => { calls.push([endpoint, payload]); return {} })", + " sandbox.__flybot__.host = async (endpoint, payload) => { hostCalls.push([endpoint, payload]); return hostImpl(endpoint, payload) }", + " const at = index++ % 11", + " if (at === 10) return [current, next => { current = typeof next === 'function' ? next(current) : next; invites.push(current) }]", + " return [at < states.length ? states[at] : value, () => {}]", + " const render = () => { index = 0; return walk(plugin.FaceToFaceGroup({})) }", + " return { calls, hostCalls, invites, render, last: () => current }", + "const plain = value => JSON.parse(JSON.stringify(value))", + "const settleUi = () => new Promise(resolve => setImmediate(resolve))", + "test('#876: a pasted group link of another FlyBot shows its room, and Join brings the picked Bots', async () => {", + " const d = localLinkDialog(async (endpoint) => {", + " if (endpoint === 'room-invite-preview') return { preview: { handle: '@chief.alice.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', server: true, name: 'Trip plan', lead: 'Chief', owner: 'Alice', expiresAt: Date.now() + 300_000, claimed: false } }", + " if (endpoint === 'room-invite-ask') return { sent: 'allow', claimed: true, bots: ['@chief.bob.home', '@scout.bob.home'] }", + " d.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick()", + " await settleUi()", + " assert.deepEqual(plain(d.hostCalls[0]), ['room-invite-preview', { link: LOCAL_URL }])", + " const preview = d.last().preview", + " assert.equal(preview.name, 'Trip plan')", + " assert.equal(preview.lead, 'Chief (Alice)')", + " assert.equal(preview.local, true)", + " const tree = d.render()", + " const card = tree.find(n => n.props['data-flybot'] === 'face-link-preview')", + " assert.ok(card, 'the one shared preview card is drawn')", + " const join = tree.find(n => n.props['data-flybot'] === 'face-link-preview-join')", + " assert.equal(join.children.join(''), 'Join group')", + " join.props.onClick()", + " assert.deepEqual(plain(d.hostCalls[1]), ['room-invite-ask', { link: LOCAL_URL, accept: true, bots: ['a', 'b'] }], 'both picked Bots, by their ids here')", + " assert.match(d.last().status.text, /^Asked to join \"Trip plan\"\\. It opens in your sidebar/)", + " assert.deepEqual(d.calls, [], 'no face-room route is asked: the ticket is not a face-room invite')", + "test('#876: each refusal of a group link is its own sentence, never \"not an invite link\"', async () => {", + " const seen = new Set()", + " for (const code of ['expired', 'revoked', 'claimed-elsewhere', 'own-invite', 'busy', 'unknown', 'lookup-failed']) {", + " const d = localLinkDialog(async () => ({ preview: { handle: '@chief.alice.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', refused: code } }))", + " d.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick()", + " await settleUi()", + " const text = d.last().status.text", + " assert.equal(d.last().status.tone, 'error')", + " assert.doesNotMatch(text, /not an invite link/)", + " assert.equal(d.last().preview, undefined, `${code}: no card to join from`)", + " seen.add(text)", + " assert.equal(seen.size, 7, 'seven refusals, seven sentences')", + " // A link this home already joined with is said before anyone presses Join.", + " const taken = localLinkDialog(async () => ({ preview: { name: 'Trip plan', lead: 'Chief', expiresAt: Date.now() + 60_000, claimed: true, joined: true } }))", + " taken.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick()", + " assert.match(taken.last().status.text, /You already joined this group with that link/, '#892: claimed is this home\\'s own claim, never someone else\\'s')", + " // #899: a claim whose room never arrived is not \"joined\": it waits, and Join knocks again.", + " const stale = localLinkDialog(async (endpoint) => (endpoint === 'room-invite-ask'", + " ? { sent: 'allow', claimed: true, bots: ['@chief.bob.home'] }", + " : { preview: { name: 'Trip plan', lead: 'Chief', owner: 'Alice', claimed: true, joined: false } }))", + " stale.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick()", + " assert.doesNotMatch(stale.last().status.text, /already joined/)", + " assert.equal(stale.last().status.text, 'Waiting for Alice to let you in. If the group does not appear, press Join group to ask again.')", + " assert.equal(stale.last().preview?.local, true, 'the card stays, so Join can ask again')", + " stale.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick()", + " assert.deepEqual(plain(stale.hostCalls.at(-1)), ['room-invite-ask', { link: LOCAL_URL, accept: true, bots: ['a', 'b'] }])", + " assert.match(stale.last().status.text, /^Asked to join \"Trip plan\"/)", + " // And a refusal on Join (the claim) is said the same way.", + " const late = localLinkDialog(async (endpoint) => (endpoint === 'room-invite-ask' ? { refused: 'expired' } : {}), { link: LOCAL_URL, status: null, preview: { name: 'Trip plan', local: true, link: LOCAL_URL } })", + " late.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick()", + " assert.equal(late.last().status.text, 'That link has expired. Ask for a new one.')", + "// The Members-card row with a React stand-in that keeps every useState by its call order.", + "function memberInviteRow(host) {", + " const { registered, walk, sandbox } = loadSocialClient(async () => ({}))", + " sandbox.__flybot__.host = async (endpoint, payload) => {", + " hostCalls.push([endpoint, payload])", + " return host(endpoint, payload)", + " const states = []", + " Fragment: 'F', useEffect() {}, useMemo: fn => fn(), useRef: value => ({ current: value }),", + " const at = i++", + " if (!(at in states)) states[at] = value", + " return [states[at], (next) => { states[at] = typeof next === 'function' ? next(states[at]) : next }]", + " const draw = () => { i = 0; return walk(plugin.LocalInviteRow({ room: { id: 'r1', name: 'Trip plan' } })) }", + " const byHook = (tree, name) => tree.find(n => n.props['data-flybot'] === name)", + " const text = (tree, name) => byHook(tree, name)?.children.join('')", + " return { hostCalls, draw, byHook, text, states }", + "test('#876 / #892: the Members card row makes a link with no deadline, with its QR, copy and withdraw', async () => {", + " const url = 'https://social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv'", + " const row = memberInviteRow(async (endpoint) => {", + " if (endpoint === 'room-invite-mint') return { invite: { ticket: 'AbCdEfGhIjKlMnOpQrStUv', handle: '@chief.alice.home', url, expiresAt: null, server: true } }", + " if (endpoint === 'room-invite') return { invite: null, invites: [] }", + " return { revoked: true }", + " let tree = row.draw()", + " assert.equal(row.byHook(tree, 'room-invite-qr'), undefined, 'no QR before a link exists')", + " assert.equal(row.text(tree, 'room-invite-about'), 'Anyone you give the link to can join with their Bot, until you withdraw it. They do not become a friend.')", + " assert.doesNotMatch(row.text(tree, 'room-invite-about'), /minute/)", + " assert.equal(row.byHook(tree, 'room-invite-mint').children.join(''), 'Create link')", + " await row.byHook(tree, 'room-invite-mint').props.onClick()", + " assert.deepEqual(plain(row.hostCalls[0]), ['room-invite-mint', { roomId: 'r1' }])", + " tree = row.draw()", + " const qr = row.byHook(tree, 'room-invite-qr')", + " assert.equal(qr.props['data-url'], url, 'the QR is the link itself, with no deadline to stop it')", + " assert.ok(Number(qr.props['data-modules']) >= 21)", + " assert.equal(row.text(tree, 'room-invite-url'), url)", + " assert.equal(row.text(tree, 'room-invite-count'), 'Works until you withdraw it.')", + " assert.equal(row.byHook(tree, 'room-invite-ended'), undefined)", + " assert.equal(row.byHook(tree, 'room-invite-mint').children.join(''), 'New link')", + " assert.ok(row.byHook(tree, 'room-invite-copy'))", + " // A week on, the link still shows: nothing counts down.", + " const realNow = Date.now", + " Date.now = () => realNow() + 7 * 24 * 3600_000", + " try { assert.ok(row.byHook(row.draw(), 'room-invite-qr'), 'still live a week later') } finally { Date.now = realNow }", + " await row.byHook(row.draw(), 'room-invite-revoke').props.onClick()", + " assert.deepEqual(plain(row.hostCalls.find(([endpoint]) => endpoint === 'room-invite-revoke')), ['room-invite-revoke', { roomId: 'r1', ticket: 'AbCdEfGhIjKlMnOpQrStUv' }])", + " assert.equal(row.states[0], null)", + "test('#892: a link an older Bot Social still dates counts down, in the host\\'s words', async () => {", + " const row = memberInviteRow(async () => ({ invite: { ticket: 'AbCdEfGhIjKlMnOpQrStUv', handle: '@chief.alice.home', url: 'https://s/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv', expiresAt: Date.now() + 600_000, server: true }, invites: [] }))", + " await row.byHook(row.draw(), 'room-invite-mint').props.onClick()", + " assert.match(row.text(row.draw(), 'room-invite-count'), /^(10:00|9:5\\d) left to join$/)", + "test('#892: earlier links still work, so the row says how many and can withdraw them all', async () => {", + " const link = t => ({ ticket: t, handle: '@chief.alice.home', url: `https://s/r/@chief.alice.home/${t}`, expiresAt: null, server: true })", + " const newest = link('NewestAbCdEfGhIjKlMnOp')", + " let open = [link('OlderAAbCdEfGhIjKlMnOp'), link('OlderBAbCdEfGhIjKlMnOp'), newest]", + " const row = memberInviteRow(async (endpoint, payload) => {", + " if (endpoint === 'room-invite-mint') return { invite: newest }", + " if (endpoint === 'room-invite') return { invite: open.at(-1) ?? null, invites: open }", + " if (endpoint === 'room-invite-revoke') { open = payload.ticket ? open.filter(item => item.ticket !== payload.ticket) : []; return { revoked: true } }", + " const others = row.byHook(tree, 'room-invite-others')", + " assert.equal(others.children[0].children.join(''), '2 earlier links still work.')", + " assert.equal(row.text(tree, 'room-invite-revoke-all'), 'Withdraw all')", + " await row.byHook(tree, 'room-invite-revoke-all').props.onClick()", + " assert.deepEqual(plain(row.hostCalls.at(-1)), ['room-invite-revoke', { roomId: 'r1' }], 'no ticket: every unused link')", + " assert.equal(row.byHook(tree, 'room-invite-others'), undefined)", + " assert.equal(row.byHook(tree, 'room-invite-qr'), undefined)", + " assert.equal(row.text(tree, 'room-invite-note'), 'Every unused link was withdrawn.')", + "test('#892: the dialog names both ways in: a number entered together, or a group invite link or its QR', () => {", + " const tree = localLinkDialog(async () => ({}), { link: '', status: null }).render()", + " const find = name => tree.find(n => n.props['data-flybot'] === name)", + " assert.equal(find('face-group').props['aria-label'], 'Join or create by number or link')", + " assert.equal(find('face-group-title').children.join(''), 'Join or create by number or link')", + " const about = find('face-join-about').children.join('')", + " assert.match(about, /same 6-digit number/)", + " assert.match(about, /paste a group invite link or scan its QR code/)", + " const input = tree.find(n => n.props.className === 'fs-link-input')", + " assert.equal(input.props.placeholder, 'Or paste a group invite link (https://…/r/…)')", + " assert.equal(input.props['aria-label'], 'Group invite link')", + "test('#892: the row and the dialog speak the host\\'s language when it has a translator', async () => {", + " const { registered, walk, sandbox, entries, pluginOf } = loadSocialClient(async () => ({}))", + " sandbox.__flybot__.t = (text) => `zh:${text}`", + " sandbox.__flybot__.host = async () => ({})", + " useState: (value) => { const at = i++; if (!(at in states)) states[at] = value; return [states[at], () => {}] },", + " const tree = walk(plugin.LocalInviteRow({ room: { id: 'r1' } }))", + " assert.equal(tree.find(n => n.props.className === 'fs-label-inline').children.join(''), 'zh:Invite by link')", + " assert.equal(tree.find(n => n.props['data-flybot'] === 'room-invite-about').children.join(''), 'zh:Anyone you give the link to can join with their Bot, until you withdraw it. They do not become a friend.')", + " void entries; void pluginOf", + "// #883: a room that lives here seated a stranger's Bot through a link (#884). Its room shares go to", + "// that Bot from the Bot the link named, under the room's id, and Bot Social's room reach carries them.", + "// Nothing else reaches a Bot that is not on the roster: not a plain line, not another room, not another", + "// of this home's Bots, not another Bot of the stranger's home.", + "const SEATED = {", + " war: {", + " id: 'war', name: 'Launch plan', members: ['a', 'b'], admin: 'a', named: true, log: [], seq: 0, seen: {},", + " { kind: 'bot', id: 'a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 },", + " { kind: 'bot', id: 'b', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 },", + " { kind: 'bot', remote: true, id: '@guest.eve.home', handle: '@guest.eve.home', ownerHome: '@eve.home', ownerName: 'Eve', displayName: 'Guest', role: 'member', admittedBy: 'ticket', ticket: 'TktSeatAbCdEfGhIjKlMnO', hostBotId: 'a', joinedSeq: 1, joinedAt: 1 },", + "test('#883: a room share reaches a seat a link opened, under that room only, and nothing else gets through', async (t) => {", + " const { ctx, home, stop } = await team({ rooms: SEATED })", + " assert.deepEqual(ctx.flybot.rooms.ticketPeers(), [{ from: 'a', to: '@guest.eve.home', scope: 'war', ticket: 'TktSeatAbCdEfGhIjKlMnO' }])", + " const share = 'FLYBOT-ROOM-SHARE\\n{\"v\":1,\"op\":\"line\",\"roomId\":\"war\",\"text\":\"hi\"}'", + " const sends = () => server.calls.filter(c => c.name === 'send_as_member')", + " const ok = await ctx.flybot.bot.send({ from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } })", + " assert.equal(ok.action, 'allow', JSON.stringify(ok))", + " assert.equal(sends().length, 1)", + " assert.equal(sends()[0].args.to, '@guest.eve.home')", + " assert.equal(sends()[0].args.room_id, 'war', 'it goes under the room id, which is what Bot Social reaches by')", + " assert.equal(sends()[0].args.from_bot_id, ctx.flybot.scope('social').fields.get('a', 'member').bot_id)", + " const refused = [", + " ['a plain line', { from: 'a', to: '@guest.eve.home', text: 'hello there' }],", + " ['a plain line under the room scope', { from: 'a', to: '@guest.eve.home', text: 'hello there', via: 'message_bot', meta: { roomId: 'war', scope: 'war' } }],", + " ['another room', { from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'other' } }],", + " ['no scope', { from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war' } }],", + " ['another Bot of mine', { from: 'b', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " ['another Bot of theirs', { from: 'a', to: '@other.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " // The text itself must be the host's envelope for that room, whole.", + " ['plain text wearing the room via', { from: 'a', to: '@guest.eve.home', text: 'hello there', via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " ['an envelope for another room', { from: 'a', to: '@guest.eve.home', text: share.replace('\"war\"', '\"other\"'), via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " ['the marker inside a plain line', { from: 'a', to: '@guest.eve.home', text: `look: ${share}`, via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " for (const [why, msg] of refused) {", + " const out = await ctx.flybot.bot.send(msg)", + " assert.equal(out.action, 'deny', `${why}: ${JSON.stringify(out)}`)", + " assert.equal(sends().length, 1, 'none of those reached Bot Social')", + " // The roster sync drops friends' Bots that are no longer listed; a seat is not one (live two-home run:", + " // the guest's Bot \"left\" the room right after the first line).", + " const seat = ctx.flybot.rooms.list().find(r => r.id === 'war').participants.find(p => p.handle === '@guest.eve.home')", + " assert.equal(seat.leftSeq, undefined, 'the seat stays: its link decides, not the roster')", + "// #881 (bot-social #43/#50): a 1:1 line between two Bots that are both live in one open room names", + "// that room, so the platform's room-scoped reach decides instead of \"these Bots are strangers\". The", + "// room a local share (#852) lives in has no id on the server, so those sends keep naming none.", + "function roomServer({ send } = {}) {", + " // The far end answers the way bot-social #43 does (#881 scope 1): two Bots that meet in one open", + " // room reach each other only when the send names that room. A pair with no room, or one whose room", + " // is closed, keeps the plain 1:1 the mock always answered.", + " const openRooms = () => [...server.face.rooms.values()].filter(room => room.status !== 'closed' && room.status !== 'ended')", + " const delivered = a => ({ conversation_id: 'c1', message: { message_id: `msg-${a.from_bot_id}`, status: 'delivered' } })", + " const refuse = code => { throw Object.assign(new Error(`Bot Social send_as_member: ${code}`), { code }) }", + " server.tools.send_as_member = send ?? ((a) => {", + " const here = openRooms().filter(room => room.members.some(m => m.handle === a.to))", + " if (a.room_id) {", + " if (!here.some(room => room.room_id === a.room_id)) refuse('room_not_shared')", + " return delivered(a)", + " if (here.length) refuse('no_relationship')", + " return delivered(a)", + "async function roomTeam(t, server, { autoAccept = false } = {}) {", + " const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept }, {", + " return { ctx, call, social }", + "test('#881: a line between two Bots in one room names that room', async (t) => {", + " const server = roomServer()", + " const peerRoom = server.face.peer('123456')", + " peerRoom.members[0].published = true // a shared friend's Bot; the room-only guest case is #853", + " const { ctx, call, social } = await roomTeam(t, server, { autoAccept: true })", + " assert.ok(ctx.flybot.rooms.ofFace('frm_123456'), 'the room is hosted here')", + " // The reply to a Bot that wrote to us (`replyTarget`): the path a home uses for its first line back.", + " await ctx.flybot.grants.set('a', [{ grantee: '@coder.bob.home' }])", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'first line back', role: 'reply' })).action, 'allow')", + " assert.equal(sent.args.room_id, 'frm_123456')", + " // The owner's own typed line names the same room.", + " assert.equal(await social.sendChat({ handle: '@coder.bob.home', text: 'my line' }), '')", + " const typed = server.calls.filter(c => c.name === 'send_as_member' && c.args.typed_by_owner === true).at(-1)", + " assert.equal(typed.args.room_id, 'frm_123456')", + "test('#881: a line with no shared room names none', async (t) => {", + " const { ctx, social } = await roomTeam(t, server, { autoAccept: true })", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'plain 1:1', role: 'reply' })).action, 'allow')", + " assert.equal('room_id' in server.calls.filter(c => c.name === 'send_as_member').at(-1).args, false, 'a send outside a room names no room')", + " assert.equal(await social.sendChat({ handle: '@writer.bob.home', text: 'to another Bot' }), '')", + " assert.equal('room_id' in server.calls.filter(c => c.name === 'send_as_member').at(-1).args, false)", + "test('#881: after leaving the room the next line names none again', async (t) => {", + " peerRoom.members[0].published = true", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'in the room', role: 'reply' })).action, 'allow')", + " assert.equal(server.calls.filter(c => c.name === 'send_as_member').at(-1).args.room_id, 'frm_123456')", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'after leaving', role: 'reply' })).action, 'allow')", + " assert.equal(await social.sendChat({ handle: '@coder.bob.home', text: 'typed after leaving' }), '')", + "test('#881: the two room refusals reach the person as sentences', async (t) => {", + " const refused = code => { throw Object.assign(new Error(`Bot Social send_as_member: ${code}`), { code }) }", + " let code = 'room_not_shared'", + " const server = roomServer({ send: () => refused(code) })", + " peerRoom.members[0].published = true // a shared friend's Bot, not a room-only guest (see #853)", + " const { call, social } = await roomTeam(t, server)", + " await assert.rejects(social.sendChat({ handle: '@coder.bob.home', text: 'into the room' }), /that room is closed between these Bots now/)", + " code = 'no_relationship'", + " await assert.rejects(social.sendChat({ handle: '@coder.bob.home', text: 'into the room' }), /the room no longer lets these Bots reach each other/)", + "// #882: the room envelope's hold rule is code and docs only — nothing reds when it is widened. These", + "// cases pin the two rules: a hold with a finding of its own keeps its card, and an envelope that", + "// carries an attachment is never cleared even when its hold is a clean blanket one (#847 stays pinned", + "// by the test above).", + "test('#882: a room envelope held with a finding of its own never clears itself', async (t) => {", + " for (const holdReason of ['attachment', 'policy', 'risk']) {", + " const { ctx, call, stop } = await team()", + " const server = holdingServer({ holdReason })", + " const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } })", + " const envelope = (op, extra = {}) => `FLYBOT-ROOM-SHARE\\n${JSON.stringify({ v: 1, op, roomId: 'session-e2439f82', name: 'You, Chief, Chief', invitedHandle: '@coder.bob.home', seq: 0, ...extra })}`", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: envelope('join'), via: 'room-share' })).action, 'allow')", + " for (let i = 0; i < 50 && !server.calls.some(c => c.name === 'list_approvals'); i += 1) await new Promise(resolve => setTimeout(resolve, 10))", + " assert.equal(server.calls.some(c => c.name === 'list_approvals'), true, `${holdReason}: the hold was looked at`)", + " assert.equal(server.calls.some(c => c.name === 'decide_approval'), false, `${holdReason}: the owner still decides this one`)", + " assert.equal(server.queue.length, 1, `${holdReason}: the hold is still pending`)", + " assert.equal((await call('flybot/social/status', {})).value.approvals.length, 1, `${holdReason}: its card is listed`)", + "test('#882: an envelope carrying an attachment is not cleared even on a clean blanket hold', async (t) => {", + " const server = holdingServer({ holdReason: 'blanket_outbound' })", + " const envelope = `FLYBOT-ROOM-SHARE\\n${JSON.stringify({ v: 1, op: 'join', roomId: 'session-e2439f82', name: 'You, Chief, Chief', invitedHandle: '@coder.bob.home', seq: 0 })}`", + " const dir = mkdtempSync(join(tmpdir(), 'flybot-att-'))", + " t.after(() => rmSync(dir, { recursive: true, force: true, maxRetries: 5 }))", + " const noted = join(dir, 'notes.txt')", + " writeFileSync(noted, 'a note')", + " const file = { name: 'notes.txt', size: 6, mime: 'text/plain', path: noted }", + " // A Bot's file is shown to the owner before it leaves, so the local card comes first; approving it", + " // is what would let the transport clear Bot Social's blanket hold — and the guard must stop that.", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: envelope, attachments: [file], via: 'room-share' })).action, 'hold')", + " const card = ctx.flybot.approvals.list().at(-1)", + " assert.equal(card.request.attachments.length, 1, 'the owner sees the file first')", + " await ctx.flybot.approvals.decide(card.id, 'approve')", + " for (let i = 0; i < 50 && server.calls.filter(c => c.name === 'send_as_member').length === 0; i += 1) await new Promise(resolve => setTimeout(resolve, 10))", + " await new Promise(resolve => setTimeout(resolve, 30))", + " assert.ok(server.calls.filter(c => c.name === 'send_as_member').at(-1).args.attachments?.length, 'the file went with it')", + " assert.equal(server.calls.some(c => c.name === 'decide_approval'), false, 'a file-carrying envelope waits for the owner')", + " assert.equal(server.queue.length, 1, 'its hold is still pending')", + " assert.equal((await call('flybot/social/status', {})).value.approvals.length, 1)", + "// #881 follow-up (Reviewer's request on #886): the local-room guard had no case — dropping it kept the", + "// whole suite green. A room line names no room even when both Bots are also in one open face room.", + "test('#881: a room line names no room, even when both Bots share a face room too', async (t) => {", + " for (const via of ['room', 'room-share']) {", + " // The local room's line is the group's own record: it names no room, so the far end answers the", + " // plain 1:1 refusal. That refusal is the guard working — what this case pins is the missing", + " // `room_id` on the wire.", + " await assert.rejects(ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: `a ${via} line`, via, role: 'reply' }))", + " const sent = server.calls.filter(c => c.name === 'send_as_member').at(-1)", + " assert.equal('room_id' in sent.args, false, `via: ${via} names no room`)", + " // The control: the same pair over a plain 1:1 line does name it.", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'plain line', role: 'reply' })).action, 'allow')" + ], + "deletedByHead": [ + "import { approvalDraftText, approvalView, createSocial, draftText, holdAllowsFaceAutoSend, holdIsBlanket, homeHandleOf, onboard, onboardHome, nextOnboardDisplayName, mapJoinInviteError, parseInviteInput, inviteLink, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js'", + "import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from '../../../core/src/client/room-invite-preview.js'", + "async function team({ rooms = {} } = {}) {", + " rooms, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0,", + " assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.approvals#social-held-reply', 'flybot.settings.page#social', 'flybot.room.overview#social-face', 'flybot.room.members#social-room-invite', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval', 'flybot.session.dock#social-thinking', 'flybot.session.dock#social-paused'])", + "function holdingServer({ reasons = ['mvp.all_outbound_requires_approval'], riskFlags = [], holdReason } = {}) {", + " server.queue.push({ approval_id: id, kind: 'outbound_message', status: 'pending', bot_id: a.from_bot_id, ...(reasons.length ? { reasons } : {}), ...(riskFlags.length ? { risk_flags: riskFlags } : {}), ...(holdReason === undefined ? {} : { hold_reason: holdReason }), message: { to: { handle: a.to, display_name: 'Coder' }, payload: { text: a.text } } })", + "test('#865: the field says why Bot Social held it; `reasons` is only the fallback', () => {", + " const BLANKET = 'mvp.all_outbound_requires_approval'", + " // The field alone decides, whether or not `reasons` agrees.", + " assert.equal(holdIsBlanket({ hold_reason: 'blanket_outbound', reasons: [BLANKET] }), true)", + " assert.equal(holdIsBlanket({ hold_reason: 'blanket_outbound' }), true, 'a server that sends only the field is read')", + " assert.equal(holdIsBlanket({ hold_reason: 'blanket_outbound', reasons: [BLANKET, 'dlp.commitment'] }), true, 'the field wins over blanket-shaped reasons')", + " // Every other value keeps its card, including one this build does not know.", + " for (const reason of ['risk', 'attachment', 'policy', 'face_room_auto_send_off', 'policy.new_rule']) {", + " assert.equal(holdIsBlanket({ hold_reason: reason, reasons: [BLANKET] }), false, `${reason} is never cleared by a connector`)", + " // No field: today's allow-list, so an older server's holds are not stranded.", + " assert.equal(holdIsBlanket({ reasons: [BLANKET] }), true)", + " assert.equal(holdIsBlanket({ reasons: [BLANKET, 'dlp.commitment'] }), false)", + " assert.equal(holdIsBlanket({ reasons: [BLANKET], risk_flags: [{ category: 'money' }] }), false)", + " assert.equal(holdIsBlanket({ reasons: [] }), false)", + " // A blank field is no answer, not an answer of \"not blanket\".", + " assert.equal(holdIsBlanket({ hold_reason: '', reasons: [BLANKET] }), true)", + " // #457: the room's own switch is clearable on the face-room path only; the blanket rule itself stays strict.", + " assert.equal(holdIsBlanket({ hold_reason: 'face_room_auto_send_off' }), false)", + " assert.equal(holdAllowsFaceAutoSend({ hold_reason: 'face_room_auto_send_off' }), true)", + " assert.equal(holdAllowsFaceAutoSend({ hold_reason: 'face_room_auto_send_off', reasons: [BLANKET, 'dlp.commitment'] }), true, 'the field wins here too')", + " assert.equal(holdAllowsFaceAutoSend({ hold_reason: 'risk' }), false)", + " assert.equal(holdAllowsFaceAutoSend({ reasons: [BLANKET] }), true)", + " assert.equal(holdAllowsFaceAutoSend({ reasons: [] }), false)", + " assert.equal(holdAllowsFaceAutoSend({ hold_reason: '', reasons: [BLANKET] }), true)", + "test('#865: a hold naming anything but blanket_outbound keeps its card, and the card carries the field', async (t) => {", + " // Blanket-shaped reasons, but the field says the hold is the face-room Auto-send switch: the card stays.", + " const server = holdingServer({ holdReason: 'face_room_auto_send_off' })", + " assert.deepEqual(server.calls.filter(c => c.name === 'decide_approval'), [], 'Auto-send does not clear a hold the field does not call blanket')", + " const card = (await call('flybot/social/status', {})).value.approvals[0]", + " assert.equal(card.holdReason, 'face_room_auto_send_off', 'the view keeps the field instead of dropping it')", + " assert.equal(card.blanketHold, false)", + "test('#865: a hold the field calls blanket_outbound is cleared even with no reasons on the row', async (t) => {", + " const server = holdingServer({ reasons: [], holdReason: 'blanket_outbound' })", + " // The same confirm-code pair as any other cleared hold: the call, then the code.", + " assert.deepEqual(server.calls.filter(c => c.name === 'decide_approval').map(c => [c.args.decision, c.args.confirm_code]), [['approved', undefined], ['approved', 'C0DE02']])", + " assert.deepEqual((await call('flybot/social/status', {})).value.approvals, [], 'no card for a blanket hold')", + " // Bot Social's real shape for a face-room hold (#457, #865): a clean line is held for the room's own", + " // Auto-send switch, with the blanket reason beside it and no finding of its own. A hold with a finding", + " // names it in `hold_reason` (`risk` / `policy` / `attachment`).", + " const heldItem = ({ reasons = [], holdReason = 'face_room_auto_send_off' } = {}) => ({", + " room_id: 'frm_123456', hold_reason: holdReason, reasons: ['mvp.all_outbound_requires_approval', ...reasons],", + " // An older server sends no field at all; the blanket reason is then the whole answer.", + " const older = heldItem()", + " delete older.hold_reason", + " // Chief's switch on: its clean line goes out, approved by the switch. The hold it arrives with is the", + " // room's own switch (`face_room_auto_send_off`), which is what #457 exists to clear.", + " // A hold with a finding of its own still waits for the owner, switch or not.", + " for (const finding of [{ holdReason: 'risk', reasons: ['dlp.commitment'] }, { holdReason: 'policy', reasons: ['policy.new_rule'] }, { holdReason: 'attachment' }]) {", + " queue = [heldItem(finding)]", + " assert.deepEqual(await fanout(room, line('a', 'Chief')), { held: true, approvalId: 'apr-face-1' }, `a ${finding.holdReason} hold is never cleared by the switch`)", + " // A reason the card does not name is still a finding (#766): the rule, not \"no name I know\".", + " queue = [heldItem({ holdReason: 'policy', reasons: ['dlp.blocked'] })]", + " // A server that sends no field keeps the old allow-list, so its blanket holds still clear.", + " queue = [older]", + " assert.equal(approvals().length, 2, 'only the blanket hold and the room-switch hold were approved')", + " assert.equal(approvals().length, 2)", + " // The invite card is core's (#876); the plugin only calls it, exactly as ds-bot's bundle registers it.", + " sandbox.__flybot__.RoomInvitePreview = RoomInvitePreview", + " sandbox.__flybot__.invitePreviewOf = invitePreviewOf", + " sandbox.__flybot__.inviteCountdown = inviteCountdown", + " // #892: one entry for both ways in, a number entered together or a group's invite link.", + " assert.equal(row[1].label, 'Join or create by number or link')", + " assert.match(row[1].hint, /number/)", + " assert.match(row[1].hint, /invite link/)", + "test('#876: the invite preview markup lives in the core card, not a second copy here', async () => {", + " // The paste flow and the member-invite dialog draw one tree of elements (#876). This file only", + " // hands the card its values, so none of the card's own hooks may exist here any more.", + " for (const hook of ['face-link-preview-name', 'face-link-preview-lead', 'face-link-preview-members', 'face-link-preview-expiry', 'face-link-preview-join', 'face-link-preview-clear', 'face-link-preview-foot']) {", + " assert.equal(source.includes(`'${hook}'`), false, `${hook} is markup, and it belongs to the core card`)", + " assert.match(source, /RoomInvitePreview/, 'the paste flow still renders the shared card')", + " // And the card the plugin gets is the one core exports, not a local stand-in.", + " const tree = walk(pluginOf([]).FaceToFaceGroup ? [] : [])", + " assert.ok(tree.length >= 0)", + "// ---- #876: a group link of another FlyBot, in the same paste field and scan -----------------", + "const LOCAL_URL = 'https://bot-social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv'", + "// The Face-to-face dialog with a typed local link: `host` answers core endpoints; every setInvite is kept.", + "function localLinkDialog(hostImpl, invite = { link: LOCAL_URL, status: null }) {", + " const hostCalls = []", + " const invites = []", + " let current = invite", + " const bots = [{ id: 'a', name: 'Chief', isMain: true }, { id: 'b', name: 'Scout' }]", + " const states = ['setup', bots, ['a', 'b'], '', false, null, [], null, 0, null]", + " const { registered, walk, sandbox } = loadSocialClient(async (endpoint, payload) => { calls.push([endpoint, payload]); return {} })", + " sandbox.__flybot__.host = async (endpoint, payload) => { hostCalls.push([endpoint, payload]); return hostImpl(endpoint, payload) }", + " const at = index++ % 11", + " if (at === 10) return [current, next => { current = typeof next === 'function' ? next(current) : next; invites.push(current) }]", + " return [at < states.length ? states[at] : value, () => {}]", + " const render = () => { index = 0; return walk(plugin.FaceToFaceGroup({})) }", + " return { calls, hostCalls, invites, render, last: () => current }", + "const plain = value => JSON.parse(JSON.stringify(value))", + "const settleUi = () => new Promise(resolve => setImmediate(resolve))", + "test('#876: a pasted group link of another FlyBot shows its room, and Join brings the picked Bots', async () => {", + " const d = localLinkDialog(async (endpoint) => {", + " if (endpoint === 'room-invite-preview') return { preview: { handle: '@chief.alice.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', server: true, name: 'Trip plan', lead: 'Chief', owner: 'Alice', expiresAt: Date.now() + 300_000, claimed: false } }", + " if (endpoint === 'room-invite-ask') return { sent: 'allow', claimed: true, bots: ['@chief.bob.home', '@scout.bob.home'] }", + " d.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick()", + " await settleUi()", + " assert.deepEqual(plain(d.hostCalls[0]), ['room-invite-preview', { link: LOCAL_URL }])", + " const preview = d.last().preview", + " assert.equal(preview.name, 'Trip plan')", + " assert.equal(preview.lead, 'Chief (Alice)')", + " assert.equal(preview.local, true)", + " const tree = d.render()", + " const card = tree.find(n => n.props['data-flybot'] === 'face-link-preview')", + " assert.ok(card, 'the one shared preview card is drawn')", + " const join = tree.find(n => n.props['data-flybot'] === 'face-link-preview-join')", + " assert.equal(join.children.join(''), 'Join group')", + " join.props.onClick()", + " assert.deepEqual(plain(d.hostCalls[1]), ['room-invite-ask', { link: LOCAL_URL, accept: true, bots: ['a', 'b'] }], 'both picked Bots, by their ids here')", + " assert.match(d.last().status.text, /^Asked to join \"Trip plan\"\\. It opens in your sidebar/)", + " assert.deepEqual(d.calls, [], 'no face-room route is asked: the ticket is not a face-room invite')", + "test('#876: each refusal of a group link is its own sentence, never \"not an invite link\"', async () => {", + " const seen = new Set()", + " for (const code of ['expired', 'revoked', 'claimed-elsewhere', 'own-invite', 'busy', 'unknown', 'lookup-failed']) {", + " const d = localLinkDialog(async () => ({ preview: { handle: '@chief.alice.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', refused: code } }))", + " d.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick()", + " await settleUi()", + " const text = d.last().status.text", + " assert.equal(d.last().status.tone, 'error')", + " assert.doesNotMatch(text, /not an invite link/)", + " assert.equal(d.last().preview, undefined, `${code}: no card to join from`)", + " seen.add(text)", + " assert.equal(seen.size, 7, 'seven refusals, seven sentences')", + " // A link this home already joined with is said before anyone presses Join.", + " const taken = localLinkDialog(async () => ({ preview: { name: 'Trip plan', lead: 'Chief', expiresAt: Date.now() + 60_000, claimed: true, joined: true } }))", + " taken.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick()", + " assert.match(taken.last().status.text, /You already joined this group with that link/, '#892: claimed is this home\\'s own claim, never someone else\\'s')", + " // #899: a claim whose room never arrived is not \"joined\": it waits, and Join knocks again.", + " const stale = localLinkDialog(async (endpoint) => (endpoint === 'room-invite-ask'", + " ? { sent: 'allow', claimed: true, bots: ['@chief.bob.home'] }", + " : { preview: { name: 'Trip plan', lead: 'Chief', owner: 'Alice', claimed: true, joined: false } }))", + " stale.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick()", + " assert.doesNotMatch(stale.last().status.text, /already joined/)", + " assert.equal(stale.last().status.text, 'Waiting for Alice to let you in. If the group does not appear, press Join group to ask again.')", + " assert.equal(stale.last().preview?.local, true, 'the card stays, so Join can ask again')", + " stale.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick()", + " assert.deepEqual(plain(stale.hostCalls.at(-1)), ['room-invite-ask', { link: LOCAL_URL, accept: true, bots: ['a', 'b'] }])", + " assert.match(stale.last().status.text, /^Asked to join \"Trip plan\"/)", + " // And a refusal on Join (the claim) is said the same way.", + " const late = localLinkDialog(async (endpoint) => (endpoint === 'room-invite-ask' ? { refused: 'expired' } : {}), { link: LOCAL_URL, status: null, preview: { name: 'Trip plan', local: true, link: LOCAL_URL } })", + " late.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick()", + " assert.equal(late.last().status.text, 'That link has expired. Ask for a new one.')", + "// The Members-card row with a React stand-in that keeps every useState by its call order.", + "function memberInviteRow(host) {", + " const { registered, walk, sandbox } = loadSocialClient(async () => ({}))", + " sandbox.__flybot__.host = async (endpoint, payload) => {", + " hostCalls.push([endpoint, payload])", + " return host(endpoint, payload)", + " const states = []", + " Fragment: 'F', useEffect() {}, useMemo: fn => fn(), useRef: value => ({ current: value }),", + " const at = i++", + " if (!(at in states)) states[at] = value", + " return [states[at], (next) => { states[at] = typeof next === 'function' ? next(states[at]) : next }]", + " const draw = () => { i = 0; return walk(plugin.LocalInviteRow({ room: { id: 'r1', name: 'Trip plan' } })) }", + " const byHook = (tree, name) => tree.find(n => n.props['data-flybot'] === name)", + " const text = (tree, name) => byHook(tree, name)?.children.join('')", + " return { hostCalls, draw, byHook, text, states }", + "test('#876 / #892: the Members card row makes a link with no deadline, with its QR, copy and withdraw', async () => {", + " const url = 'https://social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv'", + " const row = memberInviteRow(async (endpoint) => {", + " if (endpoint === 'room-invite-mint') return { invite: { ticket: 'AbCdEfGhIjKlMnOpQrStUv', handle: '@chief.alice.home', url, expiresAt: null, server: true } }", + " if (endpoint === 'room-invite') return { invite: null, invites: [] }", + " return { revoked: true }", + " let tree = row.draw()", + " assert.equal(row.byHook(tree, 'room-invite-qr'), undefined, 'no QR before a link exists')", + " assert.equal(row.text(tree, 'room-invite-about'), 'Anyone you give the link to can join with their Bot, until you withdraw it. They do not become a friend.')", + " assert.doesNotMatch(row.text(tree, 'room-invite-about'), /minute/)", + " assert.equal(row.byHook(tree, 'room-invite-mint').children.join(''), 'Create link')", + " await row.byHook(tree, 'room-invite-mint').props.onClick()", + " assert.deepEqual(plain(row.hostCalls[0]), ['room-invite-mint', { roomId: 'r1' }])", + " tree = row.draw()", + " const qr = row.byHook(tree, 'room-invite-qr')", + " assert.equal(qr.props['data-url'], url, 'the QR is the link itself, with no deadline to stop it')", + " assert.ok(Number(qr.props['data-modules']) >= 21)", + " assert.equal(row.text(tree, 'room-invite-url'), url)", + " assert.equal(row.text(tree, 'room-invite-count'), 'Works until you withdraw it.')", + " assert.equal(row.byHook(tree, 'room-invite-ended'), undefined)", + " assert.equal(row.byHook(tree, 'room-invite-mint').children.join(''), 'New link')", + " assert.ok(row.byHook(tree, 'room-invite-copy'))", + " // A week on, the link still shows: nothing counts down.", + " const realNow = Date.now", + " Date.now = () => realNow() + 7 * 24 * 3600_000", + " try { assert.ok(row.byHook(row.draw(), 'room-invite-qr'), 'still live a week later') } finally { Date.now = realNow }", + " await row.byHook(row.draw(), 'room-invite-revoke').props.onClick()", + " assert.deepEqual(plain(row.hostCalls.find(([endpoint]) => endpoint === 'room-invite-revoke')), ['room-invite-revoke', { roomId: 'r1', ticket: 'AbCdEfGhIjKlMnOpQrStUv' }])", + " assert.equal(row.states[0], null)", + "test('#892: a link an older Bot Social still dates counts down, in the host\\'s words', async () => {", + " const row = memberInviteRow(async () => ({ invite: { ticket: 'AbCdEfGhIjKlMnOpQrStUv', handle: '@chief.alice.home', url: 'https://s/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv', expiresAt: Date.now() + 600_000, server: true }, invites: [] }))", + " await row.byHook(row.draw(), 'room-invite-mint').props.onClick()", + " assert.match(row.text(row.draw(), 'room-invite-count'), /^(10:00|9:5\\d) left to join$/)", + "test('#892: earlier links still work, so the row says how many and can withdraw them all', async () => {", + " const link = t => ({ ticket: t, handle: '@chief.alice.home', url: `https://s/r/@chief.alice.home/${t}`, expiresAt: null, server: true })", + " const newest = link('NewestAbCdEfGhIjKlMnOp')", + " let open = [link('OlderAAbCdEfGhIjKlMnOp'), link('OlderBAbCdEfGhIjKlMnOp'), newest]", + " const row = memberInviteRow(async (endpoint, payload) => {", + " if (endpoint === 'room-invite-mint') return { invite: newest }", + " if (endpoint === 'room-invite') return { invite: open.at(-1) ?? null, invites: open }", + " if (endpoint === 'room-invite-revoke') { open = payload.ticket ? open.filter(item => item.ticket !== payload.ticket) : []; return { revoked: true } }", + " const others = row.byHook(tree, 'room-invite-others')", + " assert.equal(others.children[0].children.join(''), '2 earlier links still work.')", + " assert.equal(row.text(tree, 'room-invite-revoke-all'), 'Withdraw all')", + " await row.byHook(tree, 'room-invite-revoke-all').props.onClick()", + " assert.deepEqual(plain(row.hostCalls.at(-1)), ['room-invite-revoke', { roomId: 'r1' }], 'no ticket: every unused link')", + " assert.equal(row.byHook(tree, 'room-invite-others'), undefined)", + " assert.equal(row.byHook(tree, 'room-invite-qr'), undefined)", + " assert.equal(row.text(tree, 'room-invite-note'), 'Every unused link was withdrawn.')", + "test('#892: the dialog names both ways in: a number entered together, or a group invite link or its QR', () => {", + " const tree = localLinkDialog(async () => ({}), { link: '', status: null }).render()", + " const find = name => tree.find(n => n.props['data-flybot'] === name)", + " assert.equal(find('face-group').props['aria-label'], 'Join or create by number or link')", + " assert.equal(find('face-group-title').children.join(''), 'Join or create by number or link')", + " const about = find('face-join-about').children.join('')", + " assert.match(about, /same 6-digit number/)", + " assert.match(about, /paste a group invite link or scan its QR code/)", + " const input = tree.find(n => n.props.className === 'fs-link-input')", + " assert.equal(input.props.placeholder, 'Or paste a group invite link (https://…/r/…)')", + " assert.equal(input.props['aria-label'], 'Group invite link')", + "test('#892: the row and the dialog speak the host\\'s language when it has a translator', async () => {", + " const { registered, walk, sandbox, entries, pluginOf } = loadSocialClient(async () => ({}))", + " sandbox.__flybot__.t = (text) => `zh:${text}`", + " sandbox.__flybot__.host = async () => ({})", + " useState: (value) => { const at = i++; if (!(at in states)) states[at] = value; return [states[at], () => {}] },", + " const tree = walk(plugin.LocalInviteRow({ room: { id: 'r1' } }))", + " assert.equal(tree.find(n => n.props.className === 'fs-label-inline').children.join(''), 'zh:Invite by link')", + " assert.equal(tree.find(n => n.props['data-flybot'] === 'room-invite-about').children.join(''), 'zh:Anyone you give the link to can join with their Bot, until you withdraw it. They do not become a friend.')", + " void entries; void pluginOf", + "// #883: a room that lives here seated a stranger's Bot through a link (#884). Its room shares go to", + "// that Bot from the Bot the link named, under the room's id, and Bot Social's room reach carries them.", + "// Nothing else reaches a Bot that is not on the roster: not a plain line, not another room, not another", + "// of this home's Bots, not another Bot of the stranger's home.", + "const SEATED = {", + " war: {", + " id: 'war', name: 'Launch plan', members: ['a', 'b'], admin: 'a', named: true, log: [], seq: 0, seen: {},", + " { kind: 'bot', id: 'a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 },", + " { kind: 'bot', id: 'b', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 },", + " { kind: 'bot', remote: true, id: '@guest.eve.home', handle: '@guest.eve.home', ownerHome: '@eve.home', ownerName: 'Eve', displayName: 'Guest', role: 'member', admittedBy: 'ticket', ticket: 'TktSeatAbCdEfGhIjKlMnO', hostBotId: 'a', joinedSeq: 1, joinedAt: 1 },", + "test('#883: a room share reaches a seat a link opened, under that room only, and nothing else gets through', async (t) => {", + " const { ctx, home, stop } = await team({ rooms: SEATED })", + " assert.deepEqual(ctx.flybot.rooms.ticketPeers(), [{ from: 'a', to: '@guest.eve.home', scope: 'war', ticket: 'TktSeatAbCdEfGhIjKlMnO' }])", + " const share = 'FLYBOT-ROOM-SHARE\\n{\"v\":1,\"op\":\"line\",\"roomId\":\"war\",\"text\":\"hi\"}'", + " const sends = () => server.calls.filter(c => c.name === 'send_as_member')", + " const ok = await ctx.flybot.bot.send({ from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } })", + " assert.equal(ok.action, 'allow', JSON.stringify(ok))", + " assert.equal(sends().length, 1)", + " assert.equal(sends()[0].args.to, '@guest.eve.home')", + " assert.equal(sends()[0].args.room_id, 'war', 'it goes under the room id, which is what Bot Social reaches by')", + " assert.equal(sends()[0].args.from_bot_id, ctx.flybot.scope('social').fields.get('a', 'member').bot_id)", + " const refused = [", + " ['a plain line', { from: 'a', to: '@guest.eve.home', text: 'hello there' }],", + " ['a plain line under the room scope', { from: 'a', to: '@guest.eve.home', text: 'hello there', via: 'message_bot', meta: { roomId: 'war', scope: 'war' } }],", + " ['another room', { from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'other' } }],", + " ['no scope', { from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war' } }],", + " ['another Bot of mine', { from: 'b', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " ['another Bot of theirs', { from: 'a', to: '@other.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " // The text itself must be the host's envelope for that room, whole.", + " ['plain text wearing the room via', { from: 'a', to: '@guest.eve.home', text: 'hello there', via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " ['an envelope for another room', { from: 'a', to: '@guest.eve.home', text: share.replace('\"war\"', '\"other\"'), via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " ['the marker inside a plain line', { from: 'a', to: '@guest.eve.home', text: `look: ${share}`, via: 'room', meta: { roomId: 'war', scope: 'war' } }],", + " for (const [why, msg] of refused) {", + " const out = await ctx.flybot.bot.send(msg)", + " assert.equal(out.action, 'deny', `${why}: ${JSON.stringify(out)}`)", + " assert.equal(sends().length, 1, 'none of those reached Bot Social')", + " // The roster sync drops friends' Bots that are no longer listed; a seat is not one (live two-home run:", + " // the guest's Bot \"left\" the room right after the first line).", + " const seat = ctx.flybot.rooms.list().find(r => r.id === 'war').participants.find(p => p.handle === '@guest.eve.home')", + " assert.equal(seat.leftSeq, undefined, 'the seat stays: its link decides, not the roster')", + "// #881 (bot-social #43/#50): a 1:1 line between two Bots that are both live in one open room names", + "// that room, so the platform's room-scoped reach decides instead of \"these Bots are strangers\". The", + "// room a local share (#852) lives in has no id on the server, so those sends keep naming none.", + "test('#881: a line between two Bots in one room names that room', async (t) => {", + " assert.equal(sent.args.room_id, 'frm_123456')", + "test('#881: a line with no shared room names none', async (t) => {", + "test('#881: after leaving the room the next line names none again', async (t) => {", + "test('#881: the two room refusals reach the person as sentences', async (t) => {", + "// #882: the room envelope's hold rule is code and docs only — nothing reds when it is widened. These", + "// cases pin the two rules: a hold with a finding of its own keeps its card, and an envelope that", + "// carries an attachment is never cleared even when its hold is a clean blanket one (#847 stays pinned", + "// by the test above).", + "test('#882: a room envelope held with a finding of its own never clears itself', async (t) => {", + " for (const holdReason of ['attachment', 'policy', 'risk']) {", + " const { ctx, call, stop } = await team()", + " const server = holdingServer({ holdReason })", + " const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } })", + " const envelope = (op, extra = {}) => `FLYBOT-ROOM-SHARE\\n${JSON.stringify({ v: 1, op, roomId: 'session-e2439f82', name: 'You, Chief, Chief', invitedHandle: '@coder.bob.home', seq: 0, ...extra })}`", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: envelope('join'), via: 'room-share' })).action, 'allow')", + " for (let i = 0; i < 50 && !server.calls.some(c => c.name === 'list_approvals'); i += 1) await new Promise(resolve => setTimeout(resolve, 10))", + " assert.equal(server.calls.some(c => c.name === 'list_approvals'), true, `${holdReason}: the hold was looked at`)", + " assert.equal(server.calls.some(c => c.name === 'decide_approval'), false, `${holdReason}: the owner still decides this one`)", + " assert.equal(server.queue.length, 1, `${holdReason}: the hold is still pending`)", + " assert.equal((await call('flybot/social/status', {})).value.approvals.length, 1, `${holdReason}: its card is listed`)", + "test('#882: an envelope carrying an attachment is not cleared even on a clean blanket hold', async (t) => {", + " const server = holdingServer({ holdReason: 'blanket_outbound' })", + " const envelope = `FLYBOT-ROOM-SHARE\\n${JSON.stringify({ v: 1, op: 'join', roomId: 'session-e2439f82', name: 'You, Chief, Chief', invitedHandle: '@coder.bob.home', seq: 0 })}`", + " const dir = mkdtempSync(join(tmpdir(), 'flybot-att-'))", + " t.after(() => rmSync(dir, { recursive: true, force: true, maxRetries: 5 }))", + " const noted = join(dir, 'notes.txt')", + " writeFileSync(noted, 'a note')", + " const file = { name: 'notes.txt', size: 6, mime: 'text/plain', path: noted }", + " // A Bot's file is shown to the owner before it leaves, so the local card comes first; approving it", + " // is what would let the transport clear Bot Social's blanket hold — and the guard must stop that.", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: envelope, attachments: [file], via: 'room-share' })).action, 'hold')", + " const card = ctx.flybot.approvals.list().at(-1)", + " assert.equal(card.request.attachments.length, 1, 'the owner sees the file first')", + " await ctx.flybot.approvals.decide(card.id, 'approve')", + " for (let i = 0; i < 50 && server.calls.filter(c => c.name === 'send_as_member').length === 0; i += 1) await new Promise(resolve => setTimeout(resolve, 10))", + " await new Promise(resolve => setTimeout(resolve, 30))", + " assert.ok(server.calls.filter(c => c.name === 'send_as_member').at(-1).args.attachments?.length, 'the file went with it')", + " assert.equal(server.calls.some(c => c.name === 'decide_approval'), false, 'a file-carrying envelope waits for the owner')", + " assert.equal(server.queue.length, 1, 'its hold is still pending')", + " assert.equal((await call('flybot/social/status', {})).value.approvals.length, 1)", + "// #881 follow-up (Reviewer's request on #886): the local-room guard had no case — dropping it kept the", + "// whole suite green. A room line names no room even when both Bots are also in one open face room.", + "test('#881: a room line names no room, even when both Bots share a face room too', async (t) => {", + " for (const via of ['room', 'room-share']) {", + " // The local room's line is the group's own record: it names no room, so the far end answers the", + " // plain 1:1 refusal. That refusal is the guard working — what this case pins is the missing", + " // `room_id` on the wire.", + " await assert.rejects(ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: `a ${via} line`, via, role: 'reply' }))", + " const sent = server.calls.filter(c => c.name === 'send_as_member').at(-1)", + " assert.equal('room_id' in sent.args, false, `via: ${via} names no room`)", + " // The control: the same pair over a plain 1:1 line does name it.", + " assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'plain line', role: 'reply' })).action, 'allow')" + ] + } + ] +} diff --git a/.github/scripts/fixtures/silent-revert/907-b8b582bd.json b/.github/scripts/fixtures/silent-revert/907-b8b582bd.json new file mode 100644 index 000000000..0263f71d5 --- /dev/null +++ b/.github/scripts/fixtures/silent-revert/907-b8b582bd.json @@ -0,0 +1,557 @@ +{ + "pr": 907, + "head": "b8b582bdfc66305ddd151d4490e2e9a7bb6db257", + "base": "cbad5e58ef2816bab1a70abd7034df86665223dc", + "main": "79aa6128b6e0ef013e41c07f44815754c4e4684a", + "files": [ + { + "path": "core/client.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [] + }, + { + "path": "core/src/client/group-settings.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + " room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\\'s Bot'))),", + " room.origin === 'face-to-face' || members.length + remotes.length <= 2" + ] + }, + { + "path": "core/src/client/i18n.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [] + }, + { + "path": "core/src/host/room-membership.js", + "status": "modified", + "addedByMain": [ + " // Who may be shown as a line's author (#909). A handle is a claim the payload makes, so it is shown only", + " // when it can be traced here: the transport sender itself, a remote seat this room holds, or its inviter.", + " // Anything else is shown as the sender (a Bot), marked unverified. A non-handle id is a person or a Bot of", + " // the other home (#873): this side never resolves those, so the claim stands as it did before.", + " function verifiedAuthor(data, room, event) {", + " const claim = typeof data.authorId === 'string' ? data.authorId : ''", + " const kind = data.authorKind === 'bot' ? 'bot' : 'human'", + " const sender = String(event.fromHandle ?? '')", + " if (!claim) return sender ? { authorId: sender, authorKind: 'bot' } : { authorId: undefined, authorKind: 'human' }", + " if (!claim.startsWith('@')) return { authorId: claim, authorKind: kind }", + " const traced = claim === sender", + " || currentParticipants(room).some(p => p.remote === true && (p.handle === claim || p.id === claim))", + " || room.sharedFrom?.inviterHandle === claim", + " return traced ? { authorId: claim, authorKind: kind } : { authorId: sender || claim, authorKind: 'bot', unverified: true }", + " }", + "", + " // The transport names the Bot that delivered this, and that is the only identity this side can check:", + " // the envelope's inviter fields are display (#873 keeps its \"Shared room from Alice\"), never the gate", + " // that decides which room a share may touch (#909).", + " const fromHandle = String(event.fromHandle ?? '')", + " const fromHome = String(event.fromHome ?? '')", + " // Matched by the transport only (#909): a share that names a room of mine while arriving from a home", + " // that room does not belong to is refused, not merged in through a fallback lookup.", + " const claimed = Object.values(state.rooms || {}).find(record => record.origin === 'inbound-share'", + " && String(record.sharedFrom?.remoteRoomId) === String(data.roomId))", + " if (claimed) return { handled: true, refused: 'not-inviter', room: claimed }", + " // Only a join projects a room here. A line for a remote room nobody shared with this home used to", + " // create one, named from its own payload (#909).", + " if (data.op !== 'join') return { handled: true, refused: 'unknown-room' }", + " inviterHome: fromHome || undefined,", + " inviterHandle: fromHandle || undefined,", + " // The name is display and follows the envelope (#873); the identity follows the transport, so this", + " // path can never rewrite whose room it is (#909).", + " // The author claim follows the same rule (#909): a handle this room cannot trace — to the transport", + " // sender, to one of its own remote seats, or to its inviter — is shown as the Bot that carried the", + " // envelope, never as that handle. The line itself is kept: a forged claim must not silence a message.", + " const author = verifiedAuthor(data, room, event)", + " authorId: author.authorId,", + " authorKind: author.authorKind,", + " ...(author.unverified ? { unverified: true } : {}),", + " ...(author.authorKind === 'bot' ? { botId: author.authorId } : {})," + ], + "deletedByHead": [ + " // Who may be shown as a line's author (#909). A handle is a claim the payload makes, so it is shown only", + " // when it can be traced here: the transport sender itself, a remote seat this room holds, or its inviter.", + " // Anything else is shown as the sender (a Bot), marked unverified. A non-handle id is a person or a Bot of", + " // the other home (#873): this side never resolves those, so the claim stands as it did before.", + " function verifiedAuthor(data, room, event) {", + " const claim = typeof data.authorId === 'string' ? data.authorId : ''", + " const kind = data.authorKind === 'bot' ? 'bot' : 'human'", + " const sender = String(event.fromHandle ?? '')", + " if (!claim) return sender ? { authorId: sender, authorKind: 'bot' } : { authorId: undefined, authorKind: 'human' }", + " if (!claim.startsWith('@')) return { authorId: claim, authorKind: kind }", + " const traced = claim === sender", + " || currentParticipants(room).some(p => p.remote === true && (p.handle === claim || p.id === claim))", + " || room.sharedFrom?.inviterHandle === claim", + " return traced ? { authorId: claim, authorKind: kind } : { authorId: sender || claim, authorKind: 'bot', unverified: true }", + " }", + "", + " // The transport names the Bot that delivered this, and that is the only identity this side can check:", + " // the envelope's inviter fields are display (#873 keeps its \"Shared room from Alice\"), never the gate", + " // that decides which room a share may touch (#909).", + " const fromHandle = String(event.fromHandle ?? '')", + " const fromHome = String(event.fromHome ?? '')", + " // Matched by the transport only (#909): a share that names a room of mine while arriving from a home", + " // that room does not belong to is refused, not merged in through a fallback lookup.", + " const claimed = Object.values(state.rooms || {}).find(record => record.origin === 'inbound-share'", + " && String(record.sharedFrom?.remoteRoomId) === String(data.roomId))", + " if (claimed) return { handled: true, refused: 'not-inviter', room: claimed }", + " // Only a join projects a room here. A line for a remote room nobody shared with this home used to", + " // create one, named from its own payload (#909).", + " if (data.op !== 'join') return { handled: true, refused: 'unknown-room' }", + " inviterHome: fromHome || undefined,", + " inviterHandle: fromHandle || undefined,", + " // The name is display and follows the envelope (#873); the identity follows the transport, so this", + " // path can never rewrite whose room it is (#909).", + " // The author claim follows the same rule (#909): a handle this room cannot trace — to the transport", + " // sender, to one of its own remote seats, or to its inviter — is shown as the Bot that carried the", + " // envelope, never as that handle. The line itself is kept: a forged claim must not silence a message.", + " const author = verifiedAuthor(data, room, event)", + " authorId: author.authorId,", + " authorKind: author.authorKind,", + " ...(author.unverified ? { unverified: true } : {}),", + " ...(author.authorKind === 'bot' ? { botId: author.authorId } : {}),", + " }] : handle ? [{" + ] + }, + { + "path": "core/src/shared/room-share.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [] + }, + { + "path": "core/test/remote-room.test.js", + "status": "modified", + "addedByMain": [ + "", + "// #909: an inbound envelope may say who invited a room for display, but it never decides which room it", + "// may touch, which room gets created, or who wrote a line. The transport names the Bot that delivered it,", + "// and that is the only identity this side can check.", + "test('#909: a join from another home cannot rewrite or enter a mirror room it does not own', async (t) => {", + " const { rt } = host(t, TEAM)", + " await rt.load()", + " const { bob } = await signAliceAndBob(rt)", + " const actorB = await rt.authenticate(bob.sessionToken)", + " const join = encodeRoomShare({", + " op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice',", + " inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1,", + " })", + " const { room } = await rt.applyInboundRoomShare({", + " fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief',", + " text: join, localBotId: 'spec-b',", + " }, actorB)", + " const seats = () => rt.state.rooms[room.id].participants.length", + " const before = seats()", + " const at = rt.state.rooms[room.id].sharedFrom.inviterName", + " // Same remote room id, claiming to be the room's inviter, delivered by a home that is not it.", + " const forged = encodeRoomShare({", + " op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Mallory',", + " inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@mallory.bot.home', seq: 2,", + " const result = await rt.applyInboundRoomShare({", + " fromHandle: '@mallory.bot.home', fromHome: '@mallory.home', fromName: 'Mallory',", + " text: forged, localBotId: 'spec-b',", + " assert.equal(result.refused, 'not-inviter', 'the transport home is not the inviter of this room')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterName, at, 'the room still says who invited it')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHandle, '@chief.alice.home')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHome, '@alice.home')", + " assert.equal(seats(), before, 'nobody was seated by the refused share')", + " assert.equal(Object.values(rt.state.rooms).filter(r => r.origin === 'inbound-share' && r.sharedFrom?.remoteRoomId === 'alice-war').length, 1, 'still one mirror room')", + "})", + "test('#909: a bare line envelope projects no room', async (t) => {", + " const rooms = () => Object.values(rt.state.rooms).filter(room => room.origin === 'inbound-share').length", + " const before = rooms()", + " // A room id that exists only on the sender's side: a line must not open a copy here, whichever name it", + " // gives itself as the inviter.", + " const line = encodeRoomShare({", + " op: 'line', roomId: 'nobody-shared-this', name: 'Pantry', inviterName: 'Mallory',", + " inviterHome: '@mallory.home', inviterHandle: '@mallory.bot.home', invitedHandle: '@coder.bob.home',", + " seq: 1, who: 'Mallory', authorKind: 'bot', authorId: '@mallory.bot.home', text: 'Mallory: hi',", + " text: line, localBotId: 'spec-b',", + " assert.equal(result.refused, 'unknown-room')", + " assert.equal(result.room, undefined, 'nothing to write into')", + " assert.equal(rooms(), before, 'no room appeared')", + "test('#909: a line is never shown under a handle the room cannot trace', async (t) => {", + " const delivery = { fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', localBotId: 'spec-b' }", + " // A handle that is neither the sender, a seat of this room, nor its inviter.", + " await rt.applyInboundRoomShare({", + " ...delivery,", + " text: encodeRoomShare({", + " op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', inviterHome: '@alice.home',", + " invitedHandle: '@coder.bob.home', seq: 2, who: 'The CEO', authorKind: 'bot', authorId: '@ceo.alice.home', text: 'The CEO: ship it',", + " }),", + " const forged = rt.state.rooms[room.id].log.find(entry => /ship it/.test(entry.text || ''))", + " assert.equal(forged.authorId, '@chief.alice.home', 'the Bot that delivered it, not the claimed handle')", + " assert.equal(forged.authorKind, 'bot')", + " assert.equal(forged.unverified, true, 'the claim is marked, and the line is still there')", + " // The sender's own claim is traced, so a room line still reads as its Bot wrote it.", + " invitedHandle: '@coder.bob.home', seq: 3, authorKind: 'bot', authorId: '@chief.alice.home', text: 'mine',", + " const own = rt.state.rooms[room.id].log.find(entry => /mine/.test(entry.text || ''))", + " assert.equal(own.authorId, '@chief.alice.home')", + " assert.equal(own.unverified, undefined)" + ], + "deletedByHead": [ + "// #909: an inbound envelope may say who invited a room for display, but it never decides which room it", + "// may touch, which room gets created, or who wrote a line. The transport names the Bot that delivered it,", + "// and that is the only identity this side can check.", + "test('#909: a join from another home cannot rewrite or enter a mirror room it does not own', async (t) => {", + " inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1,", + " const { room } = await rt.applyInboundRoomShare({", + " fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief',", + " const seats = () => rt.state.rooms[room.id].participants.length", + " const before = seats()", + " const at = rt.state.rooms[room.id].sharedFrom.inviterName", + " // Same remote room id, claiming to be the room's inviter, delivered by a home that is not it.", + " op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Mallory',", + " inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@mallory.bot.home', seq: 2,", + " const result = await rt.applyInboundRoomShare({", + " fromHandle: '@mallory.bot.home', fromHome: '@mallory.home', fromName: 'Mallory',", + " text: forged, localBotId: 'spec-b',", + " }, actorB)", + " assert.equal(result.refused, 'not-inviter', 'the transport home is not the inviter of this room')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterName, at, 'the room still says who invited it')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHandle, '@chief.alice.home')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHome, '@alice.home')", + " assert.equal(seats(), before, 'nobody was seated by the refused share')", + " assert.equal(Object.values(rt.state.rooms).filter(r => r.origin === 'inbound-share' && r.sharedFrom?.remoteRoomId === 'alice-war').length, 1, 'still one mirror room')", + "})", + "test('#909: a bare line envelope projects no room', async (t) => {", + " const { rt } = host(t, TEAM)", + " await rt.load()", + " const { bob } = await signAliceAndBob(rt)", + " const actorB = await rt.authenticate(bob.sessionToken)", + " const rooms = () => Object.values(rt.state.rooms).filter(room => room.origin === 'inbound-share').length", + " const before = rooms()", + " // A room id that exists only on the sender's side: a line must not open a copy here, whichever name it", + " // gives itself as the inviter.", + " const line = encodeRoomShare({", + " op: 'line', roomId: 'nobody-shared-this', name: 'Pantry', inviterName: 'Mallory',", + " inviterHome: '@mallory.home', inviterHandle: '@mallory.bot.home', invitedHandle: '@coder.bob.home',", + " seq: 1, who: 'Mallory', authorKind: 'bot', authorId: '@mallory.bot.home', text: 'Mallory: hi',", + " text: line, localBotId: 'spec-b',", + " assert.equal(result.refused, 'unknown-room')", + " assert.equal(result.room, undefined, 'nothing to write into')", + " assert.equal(rooms(), before, 'no room appeared')", + "test('#909: a line is never shown under a handle the room cannot trace', async (t) => {", + " const join = encodeRoomShare({", + " })", + " text: join, localBotId: 'spec-b',", + " const delivery = { fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', localBotId: 'spec-b' }", + " // A handle that is neither the sender, a seat of this room, nor its inviter.", + " ...delivery,", + " text: encodeRoomShare({", + " op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', inviterHome: '@alice.home',", + " invitedHandle: '@coder.bob.home', seq: 2, who: 'The CEO', authorKind: 'bot', authorId: '@ceo.alice.home', text: 'The CEO: ship it',", + " }),", + " const forged = rt.state.rooms[room.id].log.find(entry => /ship it/.test(entry.text || ''))", + " assert.equal(forged.authorId, '@chief.alice.home', 'the Bot that delivered it, not the claimed handle')", + " assert.equal(forged.authorKind, 'bot')", + " assert.equal(forged.unverified, true, 'the claim is marked, and the line is still there')", + " // The sender's own claim is traced, so a room line still reads as its Bot wrote it.", + " invitedHandle: '@coder.bob.home', seq: 3, authorKind: 'bot', authorId: '@chief.alice.home', text: 'mine',", + " const own = rt.state.rooms[room.id].log.find(entry => /mine/.test(entry.text || ''))", + " assert.equal(own.authorId, '@chief.alice.home')", + " assert.equal(own.unverified, undefined)" + ] + }, + { + "path": "core/test/room-ticket-seat.test.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + " assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['chief-b', 'helper-b'])" + ] + }, + { + "path": "docs/subsystems/core-client.md", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + "- A local group's **Members · N** card (`group-settings.js`) ends with the plugin rows of slot `flybot.room.members` (`FlyBotRoomMembers` in `flybot-slots.js`), after the note on who leads the group (#892) and on the card's own row padding (`.bt-flybot-room-members`), never in a face or shared (`inbound-share`) room; the rows get `host`, the core endpoint call (also `__flybot__.host`), for `room-invite` / `room-invite-mint` / `room-invite-revoke` (#876, the social plugin's **Invite by link**). Covered by `core/test/room-members-slot.test.js`. Plugin copy that core's dictionary carries is translated through `__flybot__.t` (#892: Invite by link, the Join-or-create dialog), so a zh UI reads it in Chinese. A room a link opened is titled by its own name on the joining home (`roomTitle` in `remote-label.js`: sidebar, header, drawer, brand); \"Shared room from …\" stays as the sidebar row's hover and the **Shared** tag's title (#892)." + ] + }, + { + "path": "docs/subsystems/core-host.md", + "status": "modified", + "addedByMain": [ + "- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \\** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode." + ], + "deletedByHead": [ + "- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \\** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode.", + "- **A part is committed before its handoff runs** (`parts.js` `moveOn`): the Bot's `sessionId` / `room.sessions`, the new segment and the `rescued` mark are saved first, so the turn the handoff starts can never see an uncommitted part — a failure handler that did would start a second switch against it and lose the previous part's note, and the rescue would not know it may only happen once. The handoff package is written next; if that fails, the pointer is rolled back (best effort saved, the fresh Session archived, the rescued mark dropped), so the old part is current and the finalizer hands the input on there. Two windows stay open, and both are written down rather than discovered later: a crash after that save and before the handoff package is flushed leaves the fresh part current with no package — the stopped turn's input still goes to it, so no work is lost, but the note is, and the rollback never runs; and the rollback's own save is best effort (`rt.save().catch(() => {})`), so a failing rollback leaves the file showing the fresh part while memory holds the old one, and the next start behaves like the first window. Neither is a reason to clear the pointer before the save: a note lost this way is a part without a handoff note, while clearing first loses the handoff package itself. The move is marked done only after the stopped turn's input and the old inbox messages are forwarded; a switch that never starts (no place, another switch, a read-only host) lets the step continue instead of rejecting its messages, which DSH has already taken out of the inbox. An armed scheduled task keeps the earlier Session active and DSH refuses to archive it, so the switch keeps the `moveSchedules` promise and waits for the move before archiving the earlier part; when the move failed and the refusal reports `schedule` as the only activity, the plain archive is retried with `{ stopActivity: true }` (the way a deletion does, `roster.js:450`), so a finished part is never left active with a task nobody sees; a refusal caused by a job, turn or subagent is not retried, because `stopActivity` would stop that too (#896). Covered by `core/test/work-dir.test.js`, `core/test/part-archive.test.js` and `core/test/bot-messages.test.js`." + ] + }, + { + "path": "patches/ds-bot/0001-core-fixes.patch", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + "@@ -41,22 +41,67 @@ const ZH = {" + ] + }, + { + "path": "patches/ds-bot/0008-client-bundle.patch", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + "@@ -78,21 +78,66 @@ var ZH = {", + "@@ -8999,86 +8999,139 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {", + "+ room2.origin === \"face-to-face\" ? null : (0, import_react30.createElement)(\"span\", { className: \"bt-tag\" }, t(\"Friend's Bot\"))", + "+ room2.origin === \"face-to-face\" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)(\"button\", { type: \"button\", className: \"bt-x\", \"aria-label\": t(\"Remove {name} from the group\", { name: remoteBotTitle(person) }), title: t(\"Remove from group\"), onClick: () => update({ removeParticipants: [{ kind: \"bot\", id: person.handle || person.id, remote: true }] }) }, \"×\")" + ] + }, + { + "path": "patches/ds-bot/0012-room-membership.patch", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + "@@ -65,37 +65,67 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {", + "+ room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\\'s Bot'))),", + "+ room.origin === 'face-to-face' || members.length + remotes.length <= 2" + ] + }, + { + "path": "patches/ds-bot/files/src/host/room-membership.js", + "status": "modified", + "addedByMain": [ + " // Who may be shown as a line's author (#909). A handle is a claim the payload makes, so it is shown only", + " // when it can be traced here: the transport sender itself, a remote seat this room holds, or its inviter.", + " // Anything else is shown as the sender (a Bot), marked unverified. A non-handle id is a person or a Bot of", + " // the other home (#873): this side never resolves those, so the claim stands as it did before.", + " function verifiedAuthor(data, room, event) {", + " const claim = typeof data.authorId === 'string' ? data.authorId : ''", + " const kind = data.authorKind === 'bot' ? 'bot' : 'human'", + " const sender = String(event.fromHandle ?? '')", + " if (!claim) return sender ? { authorId: sender, authorKind: 'bot' } : { authorId: undefined, authorKind: 'human' }", + " if (!claim.startsWith('@')) return { authorId: claim, authorKind: kind }", + " const traced = claim === sender", + " || currentParticipants(room).some(p => p.remote === true && (p.handle === claim || p.id === claim))", + " || room.sharedFrom?.inviterHandle === claim", + " return traced ? { authorId: claim, authorKind: kind } : { authorId: sender || claim, authorKind: 'bot', unverified: true }", + " }", + "", + " // The transport names the Bot that delivered this, and that is the only identity this side can check:", + " // the envelope's inviter fields are display (#873 keeps its \"Shared room from Alice\"), never the gate", + " // that decides which room a share may touch (#909).", + " const fromHandle = String(event.fromHandle ?? '')", + " const fromHome = String(event.fromHome ?? '')", + " // Matched by the transport only (#909): a share that names a room of mine while arriving from a home", + " // that room does not belong to is refused, not merged in through a fallback lookup.", + " const claimed = Object.values(state.rooms || {}).find(record => record.origin === 'inbound-share'", + " && String(record.sharedFrom?.remoteRoomId) === String(data.roomId))", + " if (claimed) return { handled: true, refused: 'not-inviter', room: claimed }", + " // Only a join projects a room here. A line for a remote room nobody shared with this home used to", + " // create one, named from its own payload (#909).", + " if (data.op !== 'join') return { handled: true, refused: 'unknown-room' }", + " inviterHome: fromHome || undefined,", + " inviterHandle: fromHandle || undefined,", + " // The name is display and follows the envelope (#873); the identity follows the transport, so this", + " // path can never rewrite whose room it is (#909).", + " // The author claim follows the same rule (#909): a handle this room cannot trace — to the transport", + " // sender, to one of its own remote seats, or to its inviter — is shown as the Bot that carried the", + " // envelope, never as that handle. The line itself is kept: a forged claim must not silence a message.", + " const author = verifiedAuthor(data, room, event)", + " authorId: author.authorId,", + " authorKind: author.authorKind,", + " ...(author.unverified ? { unverified: true } : {}),", + " ...(author.authorKind === 'bot' ? { botId: author.authorId } : {})," + ], + "deletedByHead": [ + " // Who may be shown as a line's author (#909). A handle is a claim the payload makes, so it is shown only", + " // when it can be traced here: the transport sender itself, a remote seat this room holds, or its inviter.", + " // Anything else is shown as the sender (a Bot), marked unverified. A non-handle id is a person or a Bot of", + " // the other home (#873): this side never resolves those, so the claim stands as it did before.", + " function verifiedAuthor(data, room, event) {", + " const claim = typeof data.authorId === 'string' ? data.authorId : ''", + " const kind = data.authorKind === 'bot' ? 'bot' : 'human'", + " const sender = String(event.fromHandle ?? '')", + " if (!claim) return sender ? { authorId: sender, authorKind: 'bot' } : { authorId: undefined, authorKind: 'human' }", + " if (!claim.startsWith('@')) return { authorId: claim, authorKind: kind }", + " const traced = claim === sender", + " || currentParticipants(room).some(p => p.remote === true && (p.handle === claim || p.id === claim))", + " || room.sharedFrom?.inviterHandle === claim", + " return traced ? { authorId: claim, authorKind: kind } : { authorId: sender || claim, authorKind: 'bot', unverified: true }", + " }", + "", + " // The transport names the Bot that delivered this, and that is the only identity this side can check:", + " // the envelope's inviter fields are display (#873 keeps its \"Shared room from Alice\"), never the gate", + " // that decides which room a share may touch (#909).", + " const fromHandle = String(event.fromHandle ?? '')", + " const fromHome = String(event.fromHome ?? '')", + " // Matched by the transport only (#909): a share that names a room of mine while arriving from a home", + " // that room does not belong to is refused, not merged in through a fallback lookup.", + " const claimed = Object.values(state.rooms || {}).find(record => record.origin === 'inbound-share'", + " && String(record.sharedFrom?.remoteRoomId) === String(data.roomId))", + " if (claimed) return { handled: true, refused: 'not-inviter', room: claimed }", + " // Only a join projects a room here. A line for a remote room nobody shared with this home used to", + " // create one, named from its own payload (#909).", + " if (data.op !== 'join') return { handled: true, refused: 'unknown-room' }", + " inviterHome: fromHome || undefined,", + " inviterHandle: fromHandle || undefined,", + " // The name is display and follows the envelope (#873); the identity follows the transport, so this", + " // path can never rewrite whose room it is (#909).", + " // The author claim follows the same rule (#909): a handle this room cannot trace — to the transport", + " // sender, to one of its own remote seats, or to its inviter — is shown as the Bot that carried the", + " // envelope, never as that handle. The line itself is kept: a forged claim must not silence a message.", + " const author = verifiedAuthor(data, room, event)", + " authorId: author.authorId,", + " authorKind: author.authorKind,", + " ...(author.unverified ? { unverified: true } : {}),", + " ...(author.authorKind === 'bot' ? { botId: author.authorId } : {}),", + " }] : handle ? [{" + ] + }, + { + "path": "patches/ds-bot/files/src/shared/room-share.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [] + }, + { + "path": "patches/ds-bot/files/test/remote-room.test.js", + "status": "modified", + "addedByMain": [ + "", + "// #909: an inbound envelope may say who invited a room for display, but it never decides which room it", + "// may touch, which room gets created, or who wrote a line. The transport names the Bot that delivered it,", + "// and that is the only identity this side can check.", + "test('#909: a join from another home cannot rewrite or enter a mirror room it does not own', async (t) => {", + " const { rt } = host(t, TEAM)", + " await rt.load()", + " const { bob } = await signAliceAndBob(rt)", + " const actorB = await rt.authenticate(bob.sessionToken)", + " const join = encodeRoomShare({", + " op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice',", + " inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1,", + " })", + " const { room } = await rt.applyInboundRoomShare({", + " fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief',", + " text: join, localBotId: 'spec-b',", + " }, actorB)", + " const seats = () => rt.state.rooms[room.id].participants.length", + " const before = seats()", + " const at = rt.state.rooms[room.id].sharedFrom.inviterName", + " // Same remote room id, claiming to be the room's inviter, delivered by a home that is not it.", + " const forged = encodeRoomShare({", + " op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Mallory',", + " inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@mallory.bot.home', seq: 2,", + " const result = await rt.applyInboundRoomShare({", + " fromHandle: '@mallory.bot.home', fromHome: '@mallory.home', fromName: 'Mallory',", + " text: forged, localBotId: 'spec-b',", + " assert.equal(result.refused, 'not-inviter', 'the transport home is not the inviter of this room')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterName, at, 'the room still says who invited it')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHandle, '@chief.alice.home')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHome, '@alice.home')", + " assert.equal(seats(), before, 'nobody was seated by the refused share')", + " assert.equal(Object.values(rt.state.rooms).filter(r => r.origin === 'inbound-share' && r.sharedFrom?.remoteRoomId === 'alice-war').length, 1, 'still one mirror room')", + "})", + "test('#909: a bare line envelope projects no room', async (t) => {", + " const rooms = () => Object.values(rt.state.rooms).filter(room => room.origin === 'inbound-share').length", + " const before = rooms()", + " // A room id that exists only on the sender's side: a line must not open a copy here, whichever name it", + " // gives itself as the inviter.", + " const line = encodeRoomShare({", + " op: 'line', roomId: 'nobody-shared-this', name: 'Pantry', inviterName: 'Mallory',", + " inviterHome: '@mallory.home', inviterHandle: '@mallory.bot.home', invitedHandle: '@coder.bob.home',", + " seq: 1, who: 'Mallory', authorKind: 'bot', authorId: '@mallory.bot.home', text: 'Mallory: hi',", + " text: line, localBotId: 'spec-b',", + " assert.equal(result.refused, 'unknown-room')", + " assert.equal(result.room, undefined, 'nothing to write into')", + " assert.equal(rooms(), before, 'no room appeared')", + "test('#909: a line is never shown under a handle the room cannot trace', async (t) => {", + " const delivery = { fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', localBotId: 'spec-b' }", + " // A handle that is neither the sender, a seat of this room, nor its inviter.", + " await rt.applyInboundRoomShare({", + " ...delivery,", + " text: encodeRoomShare({", + " op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', inviterHome: '@alice.home',", + " invitedHandle: '@coder.bob.home', seq: 2, who: 'The CEO', authorKind: 'bot', authorId: '@ceo.alice.home', text: 'The CEO: ship it',", + " }),", + " const forged = rt.state.rooms[room.id].log.find(entry => /ship it/.test(entry.text || ''))", + " assert.equal(forged.authorId, '@chief.alice.home', 'the Bot that delivered it, not the claimed handle')", + " assert.equal(forged.authorKind, 'bot')", + " assert.equal(forged.unverified, true, 'the claim is marked, and the line is still there')", + " // The sender's own claim is traced, so a room line still reads as its Bot wrote it.", + " invitedHandle: '@coder.bob.home', seq: 3, authorKind: 'bot', authorId: '@chief.alice.home', text: 'mine',", + " const own = rt.state.rooms[room.id].log.find(entry => /mine/.test(entry.text || ''))", + " assert.equal(own.authorId, '@chief.alice.home')", + " assert.equal(own.unverified, undefined)" + ], + "deletedByHead": [ + "// #909: an inbound envelope may say who invited a room for display, but it never decides which room it", + "// may touch, which room gets created, or who wrote a line. The transport names the Bot that delivered it,", + "// and that is the only identity this side can check.", + "test('#909: a join from another home cannot rewrite or enter a mirror room it does not own', async (t) => {", + " inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1,", + " const { room } = await rt.applyInboundRoomShare({", + " fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief',", + " const seats = () => rt.state.rooms[room.id].participants.length", + " const before = seats()", + " const at = rt.state.rooms[room.id].sharedFrom.inviterName", + " // Same remote room id, claiming to be the room's inviter, delivered by a home that is not it.", + " op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Mallory',", + " inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@mallory.bot.home', seq: 2,", + " const result = await rt.applyInboundRoomShare({", + " fromHandle: '@mallory.bot.home', fromHome: '@mallory.home', fromName: 'Mallory',", + " text: forged, localBotId: 'spec-b',", + " }, actorB)", + " assert.equal(result.refused, 'not-inviter', 'the transport home is not the inviter of this room')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterName, at, 'the room still says who invited it')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHandle, '@chief.alice.home')", + " assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHome, '@alice.home')", + " assert.equal(seats(), before, 'nobody was seated by the refused share')", + " assert.equal(Object.values(rt.state.rooms).filter(r => r.origin === 'inbound-share' && r.sharedFrom?.remoteRoomId === 'alice-war').length, 1, 'still one mirror room')", + "})", + "test('#909: a bare line envelope projects no room', async (t) => {", + " const { rt } = host(t, TEAM)", + " await rt.load()", + " const { bob } = await signAliceAndBob(rt)", + " const actorB = await rt.authenticate(bob.sessionToken)", + " const rooms = () => Object.values(rt.state.rooms).filter(room => room.origin === 'inbound-share').length", + " const before = rooms()", + " // A room id that exists only on the sender's side: a line must not open a copy here, whichever name it", + " // gives itself as the inviter.", + " const line = encodeRoomShare({", + " op: 'line', roomId: 'nobody-shared-this', name: 'Pantry', inviterName: 'Mallory',", + " inviterHome: '@mallory.home', inviterHandle: '@mallory.bot.home', invitedHandle: '@coder.bob.home',", + " seq: 1, who: 'Mallory', authorKind: 'bot', authorId: '@mallory.bot.home', text: 'Mallory: hi',", + " text: line, localBotId: 'spec-b',", + " assert.equal(result.refused, 'unknown-room')", + " assert.equal(result.room, undefined, 'nothing to write into')", + " assert.equal(rooms(), before, 'no room appeared')", + "test('#909: a line is never shown under a handle the room cannot trace', async (t) => {", + " const join = encodeRoomShare({", + " })", + " text: join, localBotId: 'spec-b',", + " const delivery = { fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', localBotId: 'spec-b' }", + " // A handle that is neither the sender, a seat of this room, nor its inviter.", + " ...delivery,", + " text: encodeRoomShare({", + " op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', inviterHome: '@alice.home',", + " invitedHandle: '@coder.bob.home', seq: 2, who: 'The CEO', authorKind: 'bot', authorId: '@ceo.alice.home', text: 'The CEO: ship it',", + " }),", + " const forged = rt.state.rooms[room.id].log.find(entry => /ship it/.test(entry.text || ''))", + " assert.equal(forged.authorId, '@chief.alice.home', 'the Bot that delivered it, not the claimed handle')", + " assert.equal(forged.authorKind, 'bot')", + " assert.equal(forged.unverified, true, 'the claim is marked, and the line is still there')", + " // The sender's own claim is traced, so a room line still reads as its Bot wrote it.", + " invitedHandle: '@coder.bob.home', seq: 3, authorKind: 'bot', authorId: '@chief.alice.home', text: 'mine',", + " const own = rt.state.rooms[room.id].log.find(entry => /mine/.test(entry.text || ''))", + " assert.equal(own.authorId, '@chief.alice.home')", + " assert.equal(own.unverified, undefined)" + ] + }, + { + "path": "patches/ds-bot/files/test/room-ticket-seat.test.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + " assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['chief-b', 'helper-b'])" + ] + } + ], + "case": "907-b8b582bd", + "note": "Recorded from the real trees on 2026-10-24: base cbad5e58 is the merge base of main 79aa6128 and this tip, so A is exactly what #910 merged 40 minutes before. head b8b582bd is #907s tip before Dev3 rebased it onto 79aa6128; that rebase is why the newer tip d6e143a8 reports nothing (its base is main). D and A are the line sets of git diff -U0 over the same trees." +} diff --git a/.github/scripts/fixtures/silent-revert/910-07dee97d.json b/.github/scripts/fixtures/silent-revert/910-07dee97d.json new file mode 100644 index 000000000..3cf20a606 --- /dev/null +++ b/.github/scripts/fixtures/silent-revert/910-07dee97d.json @@ -0,0 +1,80 @@ +{ + "pr": 910, + "head": "07dee97d1495d2033d99b1ba027136a78d3e0c78", + "base": "cbad5e58ef2816bab1a70abd7034df86665223dc", + "main": "cbad5e58ef2816bab1a70abd7034df86665223dc", + "files": [ + { + "path": "core/src/host/room-membership.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + " // The envelope names the person who invited; the event names the Bot that carried the line here (#873).", + " // Prefer the envelope for the identity, and keep the transport only as the fallback, so the room reads", + " // \"Shared room from Alice\" rather than after the sender's Bot.", + " const fromHandle = data.inviterHandle || event.fromHandle", + " const fromHome = data.inviterHome || event.fromHome", + " // A share recorded before this fix stores the transport's identity, so the envelope's home would not", + " // match it and the same remote room would fork into a second `inbound-share` room. Match the values", + " // the room already has as well; the envelope still wins for what gets recorded.", + " const storedHome = event.fromHome || ''", + " const storedHandle = event.fromHandle || ''", + " if ((storedHome && storedHome !== fromHome) || (storedHandle && storedHandle !== fromHandle)) {", + " room = inboundOf(data.roomId, storedHome, storedHandle)", + " }", + " inviterHome: fromHome,", + " inviterHandle: fromHandle,", + " authorId: data.authorId || fromHandle,", + " authorKind: data.authorKind || 'human',", + " ...(data.authorKind === 'bot' ? { botId: data.authorId || fromHandle } : {})," + ] + }, + { + "path": "core/test/remote-room.test.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [] + }, + { + "path": "docs/subsystems/core-host.md", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + "- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \\** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. Which identity a projection records is the envelope's, not the transport's (#873): `applyInboundRoomShare` prefers `data.inviterName` / `inviterHome` / `inviterHandle` and keeps `event.from*` only as the fallback, so a room carried here by a Chief's Bot is still named after the person who invited it. The lookup matches the values a room already stores as well, so a projection recorded under the transport's identity before this fix resolves to the same room instead of forking a second `inbound-share` room; a line keeps its own `who`, or the sender when it names nobody, never the inviter. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode." + ] + }, + { + "path": "patches/ds-bot/files/src/host/room-membership.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [ + " // The envelope names the person who invited; the event names the Bot that carried the line here (#873).", + " // Prefer the envelope for the identity, and keep the transport only as the fallback, so the room reads", + " // \"Shared room from Alice\" rather than after the sender's Bot.", + " const fromHandle = data.inviterHandle || event.fromHandle", + " const fromHome = data.inviterHome || event.fromHome", + " // A share recorded before this fix stores the transport's identity, so the envelope's home would not", + " // match it and the same remote room would fork into a second `inbound-share` room. Match the values", + " // the room already has as well; the envelope still wins for what gets recorded.", + " const storedHome = event.fromHome || ''", + " const storedHandle = event.fromHandle || ''", + " if ((storedHome && storedHome !== fromHome) || (storedHandle && storedHandle !== fromHandle)) {", + " room = inboundOf(data.roomId, storedHome, storedHandle)", + " }", + " inviterHome: fromHome,", + " inviterHandle: fromHandle,", + " authorId: data.authorId || fromHandle,", + " authorKind: data.authorKind || 'human',", + " ...(data.authorKind === 'bot' ? { botId: data.authorId || fromHandle } : {})," + ] + }, + { + "path": "patches/ds-bot/files/test/remote-room.test.js", + "status": "modified", + "addedByMain": [], + "deletedByHead": [] + } + ], + "case": "910-07dee97d", + "note": "The negative control. Recorded at the time of its own tip: main was still cbad5e58, so A is empty by definition and the 18 lines #910 replaced are its own. Any finding here means the rule is wrong." +} diff --git a/.github/scripts/silent-revert.mjs b/.github/scripts/silent-revert.mjs new file mode 100644 index 000000000..25ee2d5c4 --- /dev/null +++ b/.github/scripts/silent-revert.mjs @@ -0,0 +1,216 @@ +// Silent revert: a branch built on an old copy of `main` deletes work `main` merged after the +// branch was cut, and CI stays green because the deletion is part of the pull request (#914). +// +// PR=12 node .github/scripts/silent-revert.mjs --repo-dir head # what the workflow runs +// node .github/scripts/silent-revert.mjs --repo-dir . --pr 12 --main origin/main +// +// Four times in one day (#864, #876, #887, #907) a pull request re-uploaded whole files from a +// stale copy: `#887` deleted `#869`'s 84 test lines merged the same hour, `#907` deleted `#909`'s +// whole fix merged 40 minutes earlier. Every one of those pull requests was `mergeable: true` +// with `tests-ok` green — the deletion *was* the pull request, so no check could see it. +// +// The rule, stated so that it can be wrong: +// base = merge-base(origin/main, HEAD) +// A = the lines `origin/main` added after `base`, per file (git diff origin/main) +// D = the lines this pull request deletes from `origin/main`, per file (git diff origin/main HEAD) +// A ∩ D non-empty -> a silent revert. Report the file, the count, and the commit that added the +// line to `main`. +// Set intersection on line text, no blame walk for the hit itself: `A` *is* the work the branch +// never saw. `#910` replaced 18 lines of the inbound trust path with its own and its `A` was empty +// at its base, so it reports nothing — that is the negative control, and it must stay zero. +// +// Report only. Read-only token, `continue-on-error`, `$GITHUB_STEP_SUMMARY` and one `::warning` per +// file: never a comment, a label, a review or a merge, and nothing reads it to block. A legitimate +// revert simply carries the report and a Reviewer decides. The script and this file are taken from +// the base commit by the workflow, so a pull request cannot weaken the check that judges it. +import { execFileSync } from 'node:child_process' +import { appendFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +// Marked `test` or `source` per finding, because a noisy test file must not hide a source file. +const TEST_PATH = /(^|\/)(test|tests|__tests__)\/|\.test\.[mc]?[jt]sx?$|\.spec\.[mc]?[jt]sx?$|(^|\/)fixtures\// + +export function isTestPath(path) { + return TEST_PATH.test(path) +} + +// Blank lines are not evidence: every file shares them, so an intersection of blank lines would +// report a revert in any pull request that re-indents a block. +export function significant(lines) { + return [...new Set((lines ?? []).map((line) => line.trimEnd()).filter((line) => line.trim() !== ''))] +} + +// The added (`+`) or removed (`-`) side of one unified diff, without the `+++`/`---` headers. +export function diffLines(text, sign) { + const out = [] + for (const line of String(text ?? '').split('\n')) { + if (line.startsWith(sign + sign + sign)) continue + if (!line.startsWith(sign)) continue + out.push(line.slice(1)) + } + return significant(out) +} + +// One file: the lines `main` added after the merge base that this pull request no longer has. +export function intersect({ path, status, addedByMain, deletedByHead }) { + const kind = isTestPath(path) ? 'test' : 'source' + const added = significant(addedByMain) + if (status === 'D') { + // A whole-file deletion is one finding with the count, not N line reports. + return added.length ? { path, kind, whole: true, count: added.length, lines: added.slice(0, 3) } : null + } + if (!added.length) return null + const wanted = new Set(added) + const hits = significant(deletedByHead).filter((line) => wanted.has(line)) + return hits.length ? { path, kind, whole: false, count: hits.length, lines: hits.slice(0, 3) } : null +} + +export function findSilentReverts({ pr, head, base, files }) { + const findings = [] + for (const file of files ?? []) { + const hit = intersect(file) + if (hit) findings.push({ ...hit, from: file.from ?? null }) + } + return { pr, head, base, scanned: (files ?? []).length, findings } +} + +export function reportLines({ pr, head, base, scanned, findings }) { + const lines = [] + for (const finding of findings) { + const from = finding.from ? ` (from ${String(finding.from).slice(0, 7)})` : '' + const what = finding.whole + ? `deleted whole, ${finding.count} line(s) main added after ${base.slice(0, 7)} are gone` + : `${finding.count} line(s) main added after ${base.slice(0, 7)} are deleted here` + lines.push(`#${pr} @${head.slice(0, 7)}: ${finding.path}: ${what}${from}`) + } + const source = findings.filter((f) => f.kind === 'source').length + const test = findings.filter((f) => f.kind === 'test').length + lines.push( + findings.length + ? `silent-revert: ${findings.length} finding(s) on ${scanned} file(s) — source ${source}, test ${test}` + // A run that prints nothing is indistinguishable from a run that never happened; #912's first + // live run was a green tick that had read nothing. + : `silent-revert: 0 finding(s) on ${scanned} file(s)`, + ) + return lines +} + +function git(dir, args) { + return execFileSync('git', ['-C', dir, ...args], { + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }) +} + +// `origin/main` is not a given: a `pull_request` checkout may hold the head only. Fetch the branch +// rather than trusting whatever the runner happens to have. +function haveRef(run, dir, ref) { + const branch = ref.replace(/^origin\//, '') + try { + run(dir, ['rev-parse', '--verify', `${ref}^{commit}`]) + } catch { + run(dir, ['fetch', '--no-tags', '--quiet', 'origin', `+refs/heads/${branch}:refs/remotes/${ref}`]) + } +} + +function parseNameStatus(text) { + const out = [] + for (const row of String(text ?? '').split('\n')) { + if (!row.trim()) continue + const [status, path] = row.split('\t') + if (!path) continue + out.push({ status: status[0], path }) + } + return out +} + +function diffSide(run, dir, from, to, path, sign) { + return diffLines(run(dir, ['diff', '--no-color', '--no-ext-diff', '-U0', from, to, '--', path]), sign) +} + +// The commit on `main` that added the line. Only used for the message, up to three lines a file. +function introducedBy(run, dir, base, main, path, lines) { + for (const line of lines) { + try { + const sha = run(dir, ['log', '--format=%H', '--reverse', `-S${line}`, `${base}..${main}`, '--', path]) + .split('\n') + .find(Boolean) + if (sha) return sha.trim() + } catch { + // A line with a leading `-` reads as an option to `git log`; the hit still stands. + continue + } + } + return null +} + +export function collect({ dir = '.', main = 'origin/main', pr = null, run = git }) { + haveRef(run, dir, main) + const head = run(dir, ['rev-parse', 'HEAD']).trim() + const mainSha = run(dir, ['rev-parse', `${main}^{commit}`]).trim() + const base = run(dir, ['merge-base', main, 'HEAD']).trim() + const files = [] + // The file list is what THIS pull request changed (base..HEAD), not what main touched since the + // merge base. With `main..HEAD` a branch one commit behind lists files it never edited; their D + // equals the A of main's commit, so every such branch would report a revert it did not make. + for (const { status, path } of parseNameStatus(run(dir, ['diff', '--name-status', base, 'HEAD']))) { + // A file this pull request adds can revert nothing, but it still counts as read: a run that + // scans nothing must not print the same clean line as a run that read the whole pull request. + if (status === 'A') { + files.push({ path, status }) + continue + } + const addedByMain = diffSide(run, dir, base, mainSha, path, '+') + if (!addedByMain.length) { + files.push({ path, status }) + continue + } + const deletedByHead = status === 'D' ? [] : diffSide(run, dir, mainSha, head, path, '-') + files.push({ path, status, addedByMain, deletedByHead }) + } + const report = findSilentReverts({ pr, head, base, files }) + return { + ...report, + findings: report.findings.map((finding) => ({ + ...finding, + from: introducedBy(run, dir, base, mainSha, finding.path, finding.lines), + })), + } +} + +// `--name value` anywhere on the command line, first position included: `--repo-dir head` is the +// workflow's own call, and reading it only from position > 0 ran the check against the wrong tree. +export function parseArgs(argv = process.argv.slice(2)) { + const arg = (name) => { + const i = argv.indexOf(name) + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined + } + return { dir: arg('--repo-dir'), pr: arg('--pr'), main: arg('--main') } +} + +export function main({ env = process.env, argv = process.argv.slice(2), cwd = process.cwd() } = {}) { + const args = parseArgs(argv) + const dir = args.dir ?? cwd + const pr = args.pr ?? env.PR ?? '' + try { + const report = collect({ dir, main: args.main ?? env.MAIN ?? 'origin/main', pr }) + const lines = reportLines(report) + for (const finding of report.findings) { + const [message] = lines.filter((line) => line.includes(`: ${finding.path}:`)) + process.stdout.write(`::warning file=${finding.path}::${message ?? finding.path}\n`) + } + process.stdout.write(lines.join('\n') + '\n') + if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n') + return report + } catch (error) { + // Shallow history, a missing ref, or no git at all: say so instead of printing a clean line. + const text = `silent-revert: could not run (${String(error.message ?? error).split('\n')[0]})` + process.stdout.write(text + '\n') + if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, text + '\n') + process.exitCode = 1 + return null + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main() diff --git a/.github/scripts/silent-revert.test.mjs b/.github/scripts/silent-revert.test.mjs new file mode 100644 index 000000000..94f525028 --- /dev/null +++ b/.github/scripts/silent-revert.test.mjs @@ -0,0 +1,220 @@ +// #914: the silent revert. A pull request built on an old copy of `main` re-uploads whole files and +// the work `main` merged after the branch was cut disappears — with `mergeable: true` and `tests-ok` +// green, because the deletion is part of the pull request. `#887` deleted `#869`'s 84 test lines the +// same hour; `#907` deleted `#909`'s whole fix 40 minutes after it merged. +// +// The fixtures under `fixtures/silent-revert/` are the real trees, recorded through the GitHub API on +// 2026-10-24 (`A` = the added side of `git diff origin/main`, `D` = the removed side of +// `git diff origin/main HEAD`, line text only, as the rule compares them). Each file says what it is. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { + collect, + diffLines, + findSilentReverts, + intersect, + isTestPath, + parseArgs, + reportLines, + significant, +} from './silent-revert.mjs' + +const fixture = (name) => + JSON.parse(readFileSync(new URL(`./fixtures/silent-revert/${name}.json`, import.meta.url), 'utf8')) +const paths = (report) => report.findings.map((finding) => finding.path) +const finding = (report, path) => report.findings.find((f) => f.path === path) + +test('the arguments are read wherever they stand, including first position', () => { + // The live run showed this: --repo-dir was the first argument, was read as absent, and the check + // ran git in the job work directory instead of the head checkout. + assert.deepEqual(parseArgs(['--repo-dir', 'head']), { dir: 'head', pr: undefined, main: undefined }) + assert.deepEqual(parseArgs(['--pr', '915', '--repo-dir', 'head', '--main', 'origin/main']), { + dir: 'head', + pr: '915', + main: 'origin/main', + }) + assert.deepEqual(parseArgs([]), { dir: undefined, pr: undefined, main: undefined }) + assert.deepEqual(parseArgs(['--pr']), { dir: undefined, pr: undefined, main: undefined }) +}) + +test('the two sides are read off a unified diff: no +++/--- headers, blank lines are not evidence', () => { + const text = ['diff --git a/x b/x', '--- a/x', '+++ b/x', '@@ -1,2 +1,3 @@', '-old', '+new', '+', '+added'].join('\n') + assert.deepEqual(diffLines(text, '+'), ['new', 'added']) + assert.deepEqual(diffLines(text, '-'), ['old']) + assert.deepEqual(significant(['a', '', ' ', 'a', 'b\t']), ['a', 'b']) +}) + +test('a file is marked test or source, so one noisy class cannot hide the other', () => { + for (const path of [ + 'plugins/social/test/social.test.js', + 'core/test/remote-room.test.js', + '.github/scripts/silent-revert.test.mjs', + 'plugins/social/__tests__/a.js', + '.github/scripts/fixtures/silent-revert/907-b8b582bd.json', + ]) { + assert.equal(isTestPath(path), true, path) + } + for (const path of ['core/src/host/room-membership.js', 'plugins/social/client.js', 'docs/subsystems/social.md']) { + assert.equal(isTestPath(path), false, path) + } +}) + +test('#907 @ b8b582bd: red — the branch never saw #909, so main fix is deleted here', () => { + const report = findSilentReverts(fixture('907-b8b582bd')) + const wanted = paths(report) + assert.ok(wanted.includes('core/src/host/room-membership.js'), wanted.join(', ')) + assert.ok(wanted.includes('core/test/remote-room.test.js'), wanted.join(', ')) + const source = finding(report, 'core/src/host/room-membership.js') + assert.ok(source.count >= 40, `count ${source.count}`) + assert.equal(source.kind, 'source') + assert.match(source.lines.join('\n'), /#909/, 'the finding names the work that was merged') + const tests = finding(report, 'core/test/remote-room.test.js') + assert.ok(tests.count >= 50, `count ${tests.count}`) + assert.equal(tests.kind, 'test') + assert.match(tests.lines.join('\n'), /#909: an inbound envelope/) + const lines = reportLines(report) + assert.match( + lines[0], + /^#907 @b8b582b: core\/src\/host\/room-membership\.js: 40 line\(s\) main added after cbad5e5 are deleted here$/, + ) + assert.match(lines.at(-1), /^silent-revert: 5 finding\(s\) on 16 file\(s\) — source 3, test 2$/) +}) + +test('#886 @ cf907be7: red on the source file and on its tests', () => { + const report = findSilentReverts(fixture('886-cf907be7')) + assert.deepEqual(paths(report).sort(), ['plugins/social/src/social.js', 'plugins/social/test/social.test.js']) + assert.equal(finding(report, 'plugins/social/src/social.js').kind, 'source') + assert.equal(finding(report, 'plugins/social/test/social.test.js').kind, 'test') + assert.ok(finding(report, 'plugins/social/test/social.test.js').count >= 270) + assert.match(reportLines(report).at(-1), /^silent-revert: 2 finding\(s\) on 2 file\(s\) — source 1, test 1$/) +}) + +test('#910 @ 07dee97d: the negative control stays zero — A was empty at its base', () => { + const report = findSilentReverts(fixture('910-07dee97d')) + assert.deepEqual(report.findings, [], 'its 18 replaced lines are its own, and main had not moved past its base') + // The clean line names how many files were read: a run that read nothing must not look the same as + // a run that read the pull request and found nothing (#912's first live run was that green tick). + assert.equal(reportLines(report).join('\n'), `silent-revert: 0 finding(s) on ${report.scanned} file(s)`) + assert.equal(report.scanned, 5) +}) + +test('a pull request already rebased onto main reports nothing: base = origin/main makes A empty', () => { + const main = '79aa6128b6e0ef013e41c07f44815754c4e4684a' + const report = findSilentReverts({ + pr: 1, + head: 'a'.repeat(40), + base: main, + // A merged copy of the file the pull request rewrites: every deleted line is main's own, and main + // added nothing after itself, so the intersection is empty by construction. + files: [{ path: 'core/src/host/room-membership.js', status: 'M', addedByMain: [], deletedByHead: ['- gone'] }], + }) + assert.deepEqual(report.findings, []) + assert.match(reportLines(report).at(-1), /^silent-revert: 0 finding\(s\) on 1 file\(s\)$/) +}) + +test('a pull request that only adds lines reports nothing, and says it read nothing', () => { + const report = findSilentReverts({ pr: 1, head: 'b'.repeat(40), base: 'c'.repeat(40), files: [] }) + assert.deepEqual(report.findings, []) + assert.equal(reportLines(report).at(-1), 'silent-revert: 0 finding(s) on 0 file(s)') +}) + +test('a whole-file deletion is one finding with the count, not one report per line', () => { + const hit = intersect({ path: 'core/src/host/channel.js', status: 'D', addedByMain: ['a', 'b', 'c', 'b'] }) + assert.equal(hit.whole, true) + assert.equal(hit.count, 3) + const report = findSilentReverts({ pr: 7, head: 'd'.repeat(40), base: 'e'.repeat(40), files: [{ ...hit, path: hit.path, status: 'D', addedByMain: ['a', 'b', 'c', 'b'] }] }) + assert.match(reportLines(report)[0], /deleted whole, 3 line\(s\) main added after eeeeeee are gone$/) +}) + +test('A ∩ D is a set: the same line on both sides counts once', () => { + const hit = intersect({ path: 'core/src/host/channel.js', status: 'M', addedByMain: ['x', 'x', 'y'], deletedByHead: ['y', 'y', 'x', 'x'] }) + assert.equal(hit.count, 2) + assert.deepEqual(hit.lines.sort(), ['x', 'y']) +}) + +// ---- collect(): the git side, which the seven recorded fixtures never touch. #915's INC found this +// the hard way: every case fed pre-recorded A/D, so flipping the A side (a change that makes the +// check permanently silent in production) left 11/11 green. +const SHA = { head: 'h'.repeat(40), main: 'm'.repeat(40), base: 'b'.repeat(40) } + +function fakeGit({ nameStatus = '', addedByFile = {}, deletedByFile = {} } = {}) { + const calls = [] + const run = (dir, args) => { + const line = args.join(' ') + calls.push({ dir, args }) + if (line === 'rev-parse --verify origin/main^{commit}') return 'ok' + if (line === 'rev-parse HEAD') return SHA.head + if (line === 'rev-parse origin/main^{commit}') return SHA.main + if (line === 'merge-base origin/main HEAD') return SHA.base + if (line === 'diff --name-status ' + SHA.base + ' HEAD') return nameStatus + const diff = /^diff --no-color --no-ext-diff -U0 (\S+) (\S+) -- (.*)$/.exec(line) + if (diff) { + const [, from, to, path] = diff + if (from === SHA.base && to === SHA.main) return (addedByFile[path] ?? []).map((l) => '+' + l).join('\n') + if (from === SHA.main && to === SHA.head) return (deletedByFile[path] ?? []).map((l) => '-' + l).join('\n') + throw new Error('diff read from the wrong side: ' + line) + } + if (args[0] === 'log') return '' + throw new Error('unexpected git call: ' + line) + } + return { run, calls } +} + +test('collect asks for the pull request own files (base..HEAD) and both sides of the rule', () => { + const file = 'core/src/host/room-membership.js' + const { run, calls } = fakeGit({ + nameStatus: 'M\t' + file, + addedByFile: { [file]: ['one', 'two'] }, + deletedByFile: { [file]: ['two', 'three'] }, + }) + const report = collect({ dir: 'head', pr: 915, run }) + const lines = calls.map((c) => c.args.join(' ')) + assert.deepEqual(lines.filter((l) => l.startsWith('diff --name-status')), ['diff --name-status ' + SHA.base + ' HEAD']) + assert.deepEqual(lines.filter((l) => l.includes(' -U0 ')), [ + 'diff --no-color --no-ext-diff -U0 ' + SHA.base + ' ' + SHA.main + ' -- ' + file, + 'diff --no-color --no-ext-diff -U0 ' + SHA.main + ' ' + SHA.head + ' -- ' + file, + ]) + assert.deepEqual(report.findings.map((f) => f.path), [file]) + assert.equal(report.findings[0].count, 1) + assert.equal(report.scanned, 1) +}) + +test('a file main touched but the pull request did not is never scanned', () => { + // #912's shape: the branch is one commit behind, so two files it never edited differ from main. + // With a main..HEAD file list their D equals main's A and every such branch reports a false revert. + const touched = 'core/src/host/room-membership.js' + const untouched = 'core/test/remote-room.test.js' + const { run } = fakeGit({ + nameStatus: 'M\t' + touched + '\nM\t.github/scripts/silent-revert.mjs\n', + addedByFile: { [touched]: ['x'], [untouched]: ['y'] }, + deletedByFile: { [touched]: ['x'], [untouched]: ['y'] }, + }) + const report = collect({ dir: 'head', pr: 912, run }) + assert.deepEqual(report.findings.map((f) => f.path), [touched], 'the file the pull request never touched cannot be reported') + assert.equal(report.scanned, 2) +}) + +test('workflow: report only, base copy of the script, full history, and it cannot turn the run red', () => { + const wf = readFileSync(new URL('../workflows/merge-gate.yml', import.meta.url), 'utf8') + assert.match(wf, /^\s+script=base\/\.github\/scripts\/silent-revert\.mjs$/m, 'the script comes from the base commit, never from the pull request') + assert.match(wf, /fetch-depth: 0/, 'merge-base and origin/main need the history') + assert.match( + wf, + /git -C head fetch --no-tags --quiet origin \+refs\/heads\/main:refs\/remotes\/origin\/main/, + 'main has to be present, not assumed', + ) + // The one documented exception, the same one review-gate.yml makes: the pull request that ADDS the + // script has no base copy, so that single run uses its own under the same read-only token. Once the + // base has the file, the base always wins. + assert.match(wf, /^\s+\[ -f "\$script" \] \|\| script=head\/\.github\/scripts\/silent-revert\.mjs$/m) + assert.match(wf, /^\s+node "\$script" --repo-dir head$/m) + // A required check that never reports leaves every pull request waiting; this one only reports, on + // the pull_request event, and its findings never fail the job. + const marker = wf.indexOf('script=base/.github/scripts/silent-revert.mjs') + const step = wf.slice(marker - 2000, marker) + assert.match(step, /if: github\.event_name == 'pull_request'/) + assert.match(step, /continue-on-error: \$\{\{ github\.event_name == 'pull_request' \}\}/) + assert.doesNotMatch(step, /: write/, 'read-only') + assert.doesNotMatch(wf, /silent-revert.*(gh pr merge|--approve|labels)/, 'it never comments, labels or merges') +}) diff --git a/.github/workflows/merge-gate.yml b/.github/workflows/merge-gate.yml index 9ac465a01..66527c77b 100644 --- a/.github/workflows/merge-gate.yml +++ b/.github/workflows/merge-gate.yml @@ -55,6 +55,33 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 + # #914: a pull request built on an old copy of main re-uploads whole files, and the work main + # merged after the branch was cut disappears while CI stays green — the deletion is the pull + # request (#887 deleted #869's 84 test lines the same hour; #907 deleted #909's fix 40 minutes + # after it merged, mergeable with tests-ok green). Its own checkout of the head, full history: + # the rule needs merge-base(origin/main, HEAD) and the commits main added after it. + # The script still comes from `base`, so a pull request cannot change the check that judges it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: github.event_name == 'pull_request' + with: + ref: ${{ github.event.pull_request.head.sha }} + path: head + fetch-depth: 0 + persist-credentials: false + # Report only: a finding is a Reviewer's read, never a red check (#914's own scope), so it + # cannot fail the job, and it stays out of branch protection and out of tests-ok's needs. + - name: Report work main merged that this pull request deletes + if: github.event_name == 'pull_request' + continue-on-error: ${{ github.event_name == 'pull_request' }} + env: + PR: ${{ github.event.pull_request.number }} + run: | + git -C head fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main + # The pull request that adds this script has no base copy yet, so that one run uses its own + # (read-only, same fallback review-gate.yml documents). Once the base has it, the base wins. + script=base/.github/scripts/silent-revert.mjs + [ -f "$script" ] || script=head/.github/scripts/silent-revert.mjs + node "$script" --repo-dir head # The script and the policy come from the base commit, so a pull request cannot weaken the gate # that judges it. On `pull_request` that also means the base copy may be older than this # workflow and refuse for lack of a conclusion — never fail the check over that: the report is diff --git a/changelog.d/914-silent-revert.added.md b/changelog.d/914-silent-revert.added.md new file mode 100644 index 000000000..b70cad504 --- /dev/null +++ b/changelog.d/914-silent-revert.added.md @@ -0,0 +1 @@ +The merge gate now reports a **silent revert**: a pull request built on an old copy of `main` re-uploads whole files, so work `main` merged after the branch was cut disappears even though everything is green — `#887` deleted `#869`'s 84 test lines the same hour, and `#907` deleted `#909`'s whole fix 40 minutes after it merged, both `mergeable` with `tests-ok` passing ([#914](https://github.com/ThinkFlowLab/FlyBot/issues/914)). It compares the lines `main` added after the merge base with the lines the pull request deletes from `main` and writes one warning per affected file plus a total line into the `merge-gate` job summary. Report only: it never comments, labels, blocks or merges, and a pull request already rebased onto `main` reports nothing. diff --git a/docs/autonomous-dev.md b/docs/autonomous-dev.md index b1c5d707b..71015848d 100644 --- a/docs/autonomous-dev.md +++ b/docs/autonomous-dev.md @@ -183,6 +183,22 @@ its report can satisfy the gate on a pull request the same account opened — th the implementer ([`AGENTS.md` → Merge gates](../AGENTS.md#merge-gates)). Nothing here blocks or merges: it is evidence an agent reads, and the record a later enforcement step is checked against. +**Silent revert (#914).** A pull request built on an old copy of `main` re-uploads whole files, and work `main` +merged after the branch was cut disappears — with `mergeable: true` and `tests-ok` green, because the deletion *is* +the pull request (`#887` deleted `#869`'s 84 test lines the same hour; `#907` deleted `#909`'s whole fix 40 minutes +after it merged). [`.github/scripts/silent-revert.mjs`](.github/scripts/silent-revert.mjs) reads the rule off git: +`base = merge-base(origin/main, HEAD)`, `A` = the lines `origin/main` added after `base`, `D` = the lines the pull +request deletes from `origin/main`, and `A ∩ D ≠ ∅` is a silent revert — reported per file with the count and the +commit that added the line to `main`. It runs as a step of `merge-gate.yml` on `pull_request` only, from the +**base** copy of the script, with a full-history checkout of the head (`fetch-depth: 0` plus an explicit +`origin/main` fetch), and writes one `::warning` per file and a total line `silent-revert: N finding(s) on M +file(s)` to the job summary — including `silent-revert: 0 finding(s) on M file(s)` when it is clean, so a run that +read nothing is not confused with a run that found nothing. Report only: `continue-on-error`, never a comment, a +label or a required check, so a legitimate revert carries the report and a Reviewer decides. A pull request already +rebased onto `main` has `A` empty and reports zero by construction — which is also this rule's boundary: a **rebase +that resolves a conflict by keeping the older side** deletes the same lines but has `main` as its base, and stays +invisible to it (`#907`'s `d6e143a8` is that case). The check catches the stale copy, not the bad merge. + ## Merge plumbing: `merge_group`, merge queue, auto-merge `main` requires a **merge queue** (repository ruleset `main merge queue`, active since 2026-10-17). `test.yml` has a diff --git a/docs/testing-layers.json b/docs/testing-layers.json index 767a326f9..fc55235bd 100644 --- a/docs/testing-layers.json +++ b/docs/testing-layers.json @@ -124,7 +124,7 @@ "layer": "L2", "gate": true, "runs": "merge_group (reports on pull_request)", - "role": "Re-reads `hold` and the newest conclusion at the entry's head, and fails the queue entry when either refuses it." + "role": "Re-reads `hold` and the newest conclusion at the entry's head, and fails the queue entry when either refuses it. On `pull_request` it also reports work `main` merged that the branch deletes (silent-revert, #914) — report only." }, { "id": "enqueue-guard", diff --git a/docs/testing-layers.md b/docs/testing-layers.md index 00d76d890..827006e9a 100644 --- a/docs/testing-layers.md +++ b/docs/testing-layers.md @@ -90,7 +90,7 @@ The tables below are generated: edit `docs/testing-layers.json`, then run | Workflow | Kind | Layer | Gate | Runs on | What it guarantees, or why it is not a layer | | --- | --- | --- | --- | --- | --- | | `test.yml` | gate | L2 | yes | push to main, pull_request, merge_group | Runs every L1 suite, the packaging and patch-pin checks, the strict typecheck, the changelog check and the workflow lint on `push`, `pull_request` and `merge_group`, and aggregates them as `tests-ok`, which `main` requires. | -| `merge-gate.yml` | gate | L2 | yes | merge_group (reports on pull_request) | Re-reads `hold` and the newest conclusion at the entry's head, and fails the queue entry when either refuses it. | +| `merge-gate.yml` | gate | L2 | yes | merge_group (reports on pull_request) | Re-reads `hold` and the newest conclusion at the entry's head, and fails the queue entry when either refuses it. On `pull_request` it also reports work `main` merged that the branch deletes (silent-revert, #914) — report only. | | `enqueue-guard.yml` | gate | L2 | yes | pull_request_target, pull_request_review, issue_comment, workflow_dispatch | Writes the `enqueue-guard` check run a merge on the fork requires: the decision of `enqueue-guard.mjs`, on the live head, from code taken from `main`. The ruleset refuses a merge while it is red (#812). | | `docker.yml` | workflow-only | L2 | yes | pull_request that touches the image | Builds the container image and starts it, so a packaging change cannot merge without the image proving it starts. | | `live-e2e.yml` | workflow-only | L3 | no | daily on main | Drives the real end-to-end suite with a real model against `main`; it is the automation behind the self-test layer, and it never gates a merge. |