From e621e3d8c936ad70987a2a5040788aac1b81c4b1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 22 Oct 2026 15:43:21 +0200 Subject: [PATCH 1/3] A Bot's Slack or Teams link code works only in a direct message Whoever sends a Reachability `link ` first is bound to the issuer's Bot: their messages run it as the issuer, and its questions and approval cards go to them. #763 made account codes DM-only for exactly this reason and recorded Reachability codes in shared channels as a known follow-up; a test pinned the old behaviour so #763 stayed in scope. A Reachability code sent in a shared conversation (or one whose kind is unknown) is now spent and the sender is told to make a new one, and an unmentioned channel post cancels Reachability codes as it already did account codes. The Reaching you copy and link instructions now say to send the code in a direct message. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++ app/src/components/delivery/reaching-you.tsx | 2 +- docs/configuration.md | 3 +- server/src/delivery/opentag-agent.ts | 54 ++++++++++++------ server/tests/delivery-routes.test.ts | 60 +++++++++++++++++--- 5 files changed, 101 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bed2c0e35e..bdc01c7800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's team account is kept, with who answered it and when. It starts empty and nothing is backfilled. +### A Bot's Slack or Teams link code works only in a direct message + +The `link ` message a Bot's Reaching you shows now links only when it is sent to the app in a +direct message. Sent in a channel, with or without a mention, it is cancelled and the sender is told +to make a new one, as an account code from Connections already was. Whoever sends the code first is +linked to that Bot: their messages run it as its owner, and its questions and approval requests go +to them, so a code others have seen must not still work. + ### A Bot with an app or web research can use its browser in a conversation again A Bot that held any app or research tool, such as Parallel Search, could not open a page, click or diff --git a/app/src/components/delivery/reaching-you.tsx b/app/src/components/delivery/reaching-you.tsx index 4a6a7f00c7..c15c980d64 100644 --- a/app/src/components/delivery/reaching-you.tsx +++ b/app/src/components/delivery/reaching-you.tsx @@ -513,7 +513,7 @@ function ConnectDialog({ - Send this message to the OpenBot app in{" "} + Send this message to the OpenBot app in a direct message in{" "} {linked.platform === "teams" ? "Microsoft Teams" : "Slack"}{" "} within {Math.ceil((linked.expiresAt - Date.now()) / 60_000)}{" "} minutes. It links that account to this conversation and Bot. diff --git a/docs/configuration.md b/docs/configuration.md index 2f0af4edf3..bf84acc5f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -476,7 +476,8 @@ Slack app of its own. Point OpenTag's `AGENT_URL` at `/api/delivery/webhooks/opentag/agent`. A person links their Slack or Teams identity from a Bot's page, under **Reaching you**: OpenBot shows a one-time `link ` message, -valid for ten minutes, which they send to the OpenTag app. After that, only that person's messages +valid for ten minutes, which they send to the OpenTag app in a direct message. A code posted in a +shared channel is cancelled, because whoever sends it first is linked to that Bot. After that, only that person's messages reach their chosen Bot, in their own OpenBot conversation, with learning, governance, approvals and audit on every turn. Anyone unlinked is told how to link and nothing runs. Approval requests appear as OpenTag approval cards (allow once, always allow, cancel) and are decided through the approvals diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 50ee2e7bc3..7e8c90fee2 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -116,8 +116,8 @@ const LINK = const LINKS = new RegExp(LINK.source, "gi"); /** - * A channel post the app was not mentioned in: everyone there has seen any account code in it, so - * each one is cancelled, quietly (an observe run never replies). The codes are redacted rather than + * A channel post the app was not mentioned in: everyone there has seen any link code in it, + * Reachability or account, so each one is cancelled, quietly (an observe run never replies). The codes are redacted rather than * the message dropped, so the post still fires the owners' message triggers without carrying a * code. A failed cancel is logged and never stops the triggers. */ @@ -128,22 +128,36 @@ async function withoutLinkCodes( ) { const codes = [...text.matchAll(LINKS)].map((m) => m[1]?.toLowerCase()); if (!codes.length) return text; - if (sender.transport === "slack" && deps.identity) - for (const code of codes) { - if (!code) continue; - try { + for (const code of codes) { + if (!code) continue; + try { + await cancelReachabilityCode(deps, code); + if (sender.transport === "slack" && deps.identity) await deps.identity.discard(code); - } catch (error) { - console.error( - JSON.stringify({ - type: "opentag-link-code-discard-error", - errorType: error instanceof Error ? error.name : "UnknownError", - }), - ); - } + } catch (error) { + console.error( + JSON.stringify({ + type: "opentag-link-code-discard-error", + errorType: error instanceof Error ? error.name : "UnknownError", + }), + ); } + } return text.replace(LINKS, "link [code removed]"); } + +/** Spends a live Reachability code without binding anyone; true when one was cancelled. */ +async function cancelReachabilityCode(deps: OpenTagAgentDeps, code: string) { + const challenge = await deps.store.readChallenge(code); + return Boolean( + challenge && + (await deps.store.consumeChallenge(code, challenge.ownerUserId)), + ); +} + +const CANCELLED_IN_SHARED = + "That code was posted where others could see it, so I cancelled it. Create a new one in OpenBot and send it to me in a direct message."; + const platformName = (transport: ChatTransport) => transport === "teams" ? "Microsoft Teams" : "Slack"; @@ -408,6 +422,14 @@ async function link( code: string, ) { const challenge = await deps.store.readChallenge(code); + // A Reachability code binds whoever sends it to its issuer's Bot: their messages run as the + // issuer, and the Bot's questions and approval requests go to them. Anyone else in a shared + // conversation has seen it and could send it first, so it is only redeemed in a direct message + // and one posted here is spent, the same rule as for an account code below. + if (challenge && !sender.private) { + await deps.store.consumeChallenge(code, challenge.ownerUserId); + return { text: CANCELLED_IN_SHARED }; + } if (!challenge && sender.transport === "slack" && deps.identity) { // Anyone else in a shared conversation has now seen the code and could send it first, binding // their own Slack account to its issuer, so an account code is only redeemed in a direct @@ -415,9 +437,7 @@ async function link( // `withoutLinkCodes`). if (!sender.private) { if (await deps.identity.discard(code)) - return { - text: "That code was posted where others could see it, so I cancelled it. Create a new one in OpenBot and send it to me in a direct message.", - }; + return { text: CANCELLED_IN_SHARED }; return { text: "That link code expired, was already used, or is for another app. Create a new one in OpenBot.", }; diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index 3602a981fe..0e20b30d82 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -36,6 +36,7 @@ function fixture( ) { const calls: unknown[] = []; const decisions: unknown[] = []; + const consumed: unknown[] = []; const twilio = createTwilioTransport({ accountSid: "AC1", authToken: "token", @@ -83,7 +84,10 @@ function fixture( address: null, } : null, - consumeChallenge: async () => true, + consumeChallenge: async (...input) => { + consumed.push(input); + return true; + }, bind: async (input) => { calls.push({ bind: input }); return { ...bound, ...input }; @@ -139,7 +143,7 @@ function fixture( inbox: async () => ({ questions: [] }), }, }); - return { routes, calls, decisions }; + return { routes, calls, decisions, consumed }; } const sender = ( user = "slack:T1:U1", @@ -322,15 +326,41 @@ test("an identity code in a direct message is never discarded", async () => { await run(f, { context: [sender()], ...linkMessage(IDENTITY_CODE) }); expect(discarded).toEqual([]); }); -test("a Reachability code still links from a shared conversation", async () => { - const { f, redeemed, discarded } = identityFixture("linked"); +const REACHABILITY_CODE = "11111111-2222-4333-8444-555555555555"; +test("a Reachability code sent where others can read it is cancelled, never bound", async () => { + // Whoever sends a Reachability code is bound to its issuer's Bot and decides its approvals as + // the issuer, so a code others have seen is spent, exactly like an account code. + for (const conversation of [{ id: "C0123456789", kind: "channel" }, null]) { + const { f, redeemed, discarded } = identityFixture("linked"); + const result = await run(f, { + context: [sender("slack:T1:U1", conversation)], + ...linkMessage(REACHABILITY_CODE), + }); + expect(said(result.events)).toBe(CANCELLED); + expect(f.consumed).toEqual([[REACHABILITY_CODE, "owner"]]); + expect(f.calls).toEqual([]); + expect(redeemed).toEqual([]); + expect(discarded).toEqual([]); + } +}); +test("a Reachability code is cancelled from a shared conversation without an account adapter", async () => { + const f = fixture({ bound: false }); const result = await run(f, { context: [sender("slack:T1:U1", { id: "C0123456789", kind: "channel" })], - ...linkMessage("11111111-2222-4333-8444-555555555555"), + ...linkMessage(REACHABILITY_CODE), + }); + expect(said(result.events)).toBe(CANCELLED); + expect(f.consumed).toEqual([[REACHABILITY_CODE, "owner"]]); + expect(f.calls).toEqual([]); +}); +test("a Reachability code in a direct message still links", async () => { + const { f } = identityFixture("linked"); + const result = await run(f, { + context: [sender()], + ...linkMessage(REACHABILITY_CODE), }); expect(said(result.events)).toContain("Linked."); - expect(redeemed).toEqual([]); - expect(discarded).toEqual([]); + expect(f.calls).toHaveLength(1); }); const observedMessage = (content: string) => ({ context: [ @@ -368,6 +398,22 @@ test("an identity code pasted in a channel without a mention is cancelled quietl ); } }); +test("a Reachability code pasted in a channel without a mention is cancelled quietly and kept out of triggers", async () => { + const { f, redeemed, discarded } = identityFixture("linked"); + const result = await run( + f, + observedMessage(`here: link ${REACHABILITY_CODE.toUpperCase()}`), + ); + expect(said(result.events)).toBe(""); + expect(f.consumed).toEqual([[REACHABILITY_CODE, "owner"]]); + expect(redeemed).toEqual([]); + expect(discarded).toEqual([REACHABILITY_CODE]); + const triggers = triggersOf(f); + expect(triggers).toHaveLength(1); + expect(JSON.stringify(triggers[0]).toLowerCase()).not.toContain( + REACHABILITY_CODE, + ); +}); test("an ordinary observed channel message reaches triggers unchanged and discards nothing", async () => { const { f, discarded } = identityFixture("linked"); const result = await run(f, observedMessage("deploy is broken")); From 1d3ec4a31e6ed27e858a10939d776e84b76a0d81 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 4 Nov 2026 16:55:57 +0100 Subject: [PATCH 2/3] Cancel each link code on its own, and pin Teams personal chats as private A channel post's Reachability cancel and account-code cancel shared one try, so a store that failed to read the Reachability challenge left an account code with the same value live. Each now fails on its own. Teams has only the conversation kind to say a chat is private, since its ids never look like a Slack DM. A test now links a Reachability code from a Teams-shaped personal chat. Co-Authored-By: Claude Opus 5.5 --- server/src/delivery/opentag-agent.ts | 31 +++++++++------ server/tests/delivery-routes.test.ts | 59 ++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 7e8c90fee2..dddee1acc4 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -130,22 +130,29 @@ async function withoutLinkCodes( if (!codes.length) return text; for (const code of codes) { if (!code) continue; - try { - await cancelReachabilityCode(deps, code); - if (sender.transport === "slack" && deps.identity) - await deps.identity.discard(code); - } catch (error) { - console.error( - JSON.stringify({ - type: "opentag-link-code-discard-error", - errorType: error instanceof Error ? error.name : "UnknownError", - }), - ); - } + // Each cancel is tried on its own: a store that fails to spend a Reachability code must not + // leave an account code with the same value live, and the other way round. + await logFailure(() => cancelReachabilityCode(deps, code)); + const identity = deps.identity; + if (sender.transport === "slack" && identity) + await logFailure(() => identity.discard(code)); } return text.replace(LINKS, "link [code removed]"); } +async function logFailure(cancel: () => Promise) { + try { + await cancel(); + } catch (error) { + console.error( + JSON.stringify({ + type: "opentag-link-code-discard-error", + errorType: error instanceof Error ? error.name : "UnknownError", + }), + ); + } +} + /** Spends a live Reachability code without binding anyone; true when one was cancelled. */ async function cancelReachabilityCode(deps: OpenTagAgentDeps, code: string) { const challenge = await deps.store.readChallenge(code); diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index 0e20b30d82..d9ee12c975 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -32,6 +32,10 @@ function fixture( converse?: ConverseResult; bound?: boolean; identity?: IdentityAdapter; + /** Makes every Reachability lookup throw, as a store that is down would. */ + challengeError?: Error; + /** The transport the Reachability code was issued for. */ + challengeTransport?: "slack" | "teams"; } = {}, ) { const calls: unknown[] = []; @@ -72,18 +76,20 @@ function fixture( }, }, store: { - readChallenge: async (id: string) => - id === "11111111-2222-4333-8444-555555555555" + readChallenge: async (id: string) => { + if (options.challengeError) throw options.challengeError; + return id === "11111111-2222-4333-8444-555555555555" ? { id, ownerUserId: "owner", channelId: "ch", agentId: "bot", threadId: "canonical", - transport: "slack", + transport: options.challengeTransport ?? "slack", address: null, } - : null, + : null; + }, consumeChallenge: async (...input) => { consumed.push(input); return true; @@ -362,6 +368,26 @@ test("a Reachability code in a direct message still links", async () => { expect(said(result.events)).toContain("Linked."); expect(f.calls).toHaveLength(1); }); +test("a Reachability code in a Teams personal chat still links", async () => { + // A Teams conversation id is never a Slack `D…` id, so the only thing that makes this chat + // private is its kind. Were `personal` not read as private, every Teams code would be spent. + const f = fixture({ bound: false, challengeTransport: "teams" }); + const result = await run(f, { + context: [ + sender("teams:tenant1:user1", { + id: "a:1aBcDeFgHiJkLmNoPqRsTuVwXyZ", + kind: "personal", + }), + ], + ...linkMessage(REACHABILITY_CODE), + }); + expect(said(result.events)).toContain("Linked."); + expect(f.calls).toEqual([ + { + bind: expect.objectContaining({ transport: "teams", identity: "user1" }), + }, + ]); +}); const observedMessage = (content: string) => ({ context: [ { @@ -414,6 +440,31 @@ test("a Reachability code pasted in a channel without a mention is cancelled qui REACHABILITY_CODE, ); }); +test("a Reachability store that fails still lets the account code with that value be cancelled", async () => { + const discarded: string[] = []; + const f = fixture({ + bound: false, + challengeError: new Error("store is down"), + identity: { + redeem: async () => "linked", + discard: async (code) => { + discarded.push(code); + return true; + }, + }, + }); + const result = await run( + f, + observedMessage(`here: link ${IDENTITY_CODE.toUpperCase()}`), + ); + expect(said(result.events)).toBe(""); + expect(discarded).toEqual([IDENTITY_CODE]); + const triggers = triggersOf(f); + expect(triggers).toHaveLength(1); + expect(JSON.stringify(triggers[0]).toLowerCase()).not.toContain( + IDENTITY_CODE, + ); +}); test("an ordinary observed channel message reaches triggers unchanged and discards nothing", async () => { const { f, discarded } = identityFixture("linked"); const result = await run(f, observedMessage("deploy is broken")); From d2e2976c646bce4682386049eaf0ed778cd9b581 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 4 Nov 2026 17:05:28 +0100 Subject: [PATCH 3/3] Keep the direct-message rule for link codes to Slack for now A Teams chat is private only by its conversation kind, and nothing yet confirms OpenTag sends `personal` for a one-to-one chat, so the rule could have spent every Reachability code sent from Teams. Teams codes link wherever they are sent, as before, until that is confirmed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++++------- docs/configuration.md | 4 ++-- server/src/delivery/opentag-agent.ts | 13 ++++++++----- server/tests/delivery-routes.test.ts | 21 +++++++++++++++++++++ 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b3a8a7a60..b80fcf2790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -759,13 +759,14 @@ opening the app's page already did. A Shared app's team account is still recorde If Composio then reports no account after all, Connect says nothing was connected and asks you to press it again. It no longer tells you to disconnect anything. -### A Bot's Slack or Teams link code works only in a direct message - -The `link ` message a Bot's Reaching you shows now links only when it is sent to the app in a -direct message. Sent in a channel, with or without a mention, it is cancelled and the sender is told -to make a new one, as an account code from Connections already was. Whoever sends the code first is -linked to that Bot: their messages run it as its owner, and its questions and approval requests go -to them, so a code others have seen must not still work. +### A Bot's Slack link code works only in a direct message + +The `link ` message a Bot's Reaching you shows now links on Slack only when it is sent to the +app in a direct message. Sent in a channel, with or without a mention, it is cancelled and the sender +is told to make a new one, as an account code from Connections already was. Whoever sends the code +first is linked to that Bot: their messages run it as its owner, and its questions and approval +requests go to them, so a code others have seen must not still work. Teams codes still link wherever +they are sent, until OpenTag is confirmed to mark a Teams one-to-one chat as private. ### A Bot with an app or web research can use its browser in a conversation again diff --git a/docs/configuration.md b/docs/configuration.md index 1f11a24334..b5c9e714c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -605,8 +605,8 @@ Slack app of its own. Point OpenTag's `AGENT_URL` at `/api/delivery/webhooks/opentag/agent`. A person links their Slack or Teams identity from a Bot's page, under **Reaching you**: OpenBot shows a one-time `link ` message, -valid for ten minutes, which they send to the OpenTag app in a direct message. A code posted in a -shared channel is cancelled, because whoever sends it first is linked to that Bot. After that, only that person's messages +valid for ten minutes, which they send to the OpenTag app in a direct message. On Slack, a code posted +in a shared channel is cancelled, because whoever sends it first is linked to that Bot. After that, only that person's messages reach their chosen Bot, in their own OpenBot conversation, with learning, governance, approvals and audit on every turn. Anyone unlinked is told how to link and nothing runs. Approval requests appear as OpenTag approval cards (allow once, always allow, cancel) and are decided through the approvals diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 1150063ab0..0a90c9860c 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -125,7 +125,8 @@ const LINKS = new RegExp(LINK.source, "gi"); /** * A channel post the app was not mentioned in: everyone there has seen any link code in it, - * Reachability or account, so each one is cancelled, quietly (an observe run never replies). The codes are redacted rather than + * Reachability or account, so on Slack each one is cancelled, quietly (an observe run never + * replies; Teams waits on the same rule as `link`). The codes are redacted rather than * the message dropped, so the post still fires the owners' message triggers without carrying a * code. A failed cancel is logged and never stops the triggers. */ @@ -140,10 +141,10 @@ async function withoutLinkCodes( if (!code) continue; // Each cancel is tried on its own: a store that fails to spend a Reachability code must not // leave an account code with the same value live, and the other way round. + if (sender.transport !== "slack") continue; await logFailure(() => cancelReachabilityCode(deps, code)); const identity = deps.identity; - if (sender.transport === "slack" && identity) - await logFailure(() => identity.discard(code)); + if (identity) await logFailure(() => identity.discard(code)); } return text.replace(LINKS, "link [code removed]"); } @@ -441,8 +442,10 @@ async function link( // A Reachability code binds whoever sends it to its issuer's Bot: their messages run as the // issuer, and the Bot's questions and approval requests go to them. Anyone else in a shared // conversation has seen it and could send it first, so it is only redeemed in a direct message - // and one posted here is spent, the same rule as for an account code below. - if (challenge && !sender.private) { + // and one posted here is spent, the same rule as for an account code below. Slack only for now: + // a Teams chat is private only by its conversation kind, and until OpenTag is confirmed to send + // `personal` for a one-to-one chat, this rule could spend every code sent from Teams. + if (challenge && sender.transport === "slack" && !sender.private) { await deps.store.consumeChallenge(code, challenge.ownerUserId); return { text: CANCELLED_IN_SHARED }; } diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index f009044f36..b92b44b162 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -400,6 +400,27 @@ test("a Reachability code in a Teams personal chat still links", async () => { }, ]); }); +test("a Reachability code in a shared Teams chat still links until Teams' private chats are confirmed", async () => { + // The direct-message rule is Slack's only for now: a Teams chat is private only by its kind, and + // nothing yet confirms OpenTag sends `personal` for one, so applying the rule could spend every + // code sent from Teams. + const f = fixture({ bound: false, challengeTransport: "teams" }); + const result = await run(f, { + context: [ + sender("teams:tenant1:user1", { + id: "19:meeting_abc@thread.v2", + kind: "groupChat", + }), + ], + ...linkMessage(REACHABILITY_CODE), + }); + expect(said(result.events)).toContain("Linked."); + expect(f.calls).toEqual([ + { + bind: expect.objectContaining({ transport: "teams", identity: "user1" }), + }, + ]); +}); const observedMessage = (content: string) => ({ context: [ {