Skip to content

Commit 25ef2d9

Browse files
authored
fix(msteams): a proactive send keeps the buttons its result carries (#330)
Crew finding F6 from the #327 review, the one with a user-visible consequence. The courier rendered through `text_reply_message`, which drops every interactive component. This feature exists to deliver a FINISHED long-running operation back to the conversation that started it, and the archetypal such result asks for approval — so the approval prompt arrived as prose with nothing to click. The captured activity before the fix, in full: {"conversation":{"id":"a:conv-1"},"from":{"id":"28:bot-1"}, "recipient":{"id":"29:1abc"}, "text":"The migration finished. Approve the cutover?", "textFormat":"plain","type":"message"} The button is simply gone. A render error was worse still: the literal text `(no content)`, posted with `result: ok`. It now uses `build_reply_body` — the inbound reply path's own renderer. That also closes the security half of the finding: `render_card_content` binds each control's correlation token to (tenant, recipient), so a proactively delivered card is a capability for its recipient rather than for whoever can see it (#250/#287). The recipient is the stored `user_id`. Red first: `a_proactive_send_delivers_the_buttons_the_result_carries` sends a result carrying an approval button and asserts the captured activity contains it, carries a correlation token (`ct` — proof it went through the SIGNING path rather than being rendered as decoration), and never contains `(no content)`. cargo test --workspace --no-fail-fast: 925 passed, 0 failed.
1 parent 47afcdc commit 25ef2d9

19 files changed

Lines changed: 156 additions & 23 deletions

File tree

‎crates/triton-adapters-http/src/identity.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ fn forwarded_email_principal(email: &str) -> Principal {
247247
raw_token: String::new(),
248248
trace_id: uuid::Uuid::new_v4().to_string(),
249249
sender_ref: None,
250+
conversation_ref: None,
250251
}
251252
}
252253

@@ -303,6 +304,7 @@ fn verify_dev_or_reject(token: &str, expected: &str) -> Result<Principal, Triton
303304
raw_token: token.into(),
304305
trace_id: uuid::Uuid::new_v4().to_string(),
305306
sender_ref: None,
307+
conversation_ref: None,
306308
})
307309
}
308310

‎crates/triton-adapters-http/src/rest.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,7 @@ mod trace_scope_tests {
10631063
raw_token: String::new(),
10641064
trace_id: "t".into(),
10651065
sender_ref: None,
1066+
conversation_ref: None,
10661067
}
10671068
}
10681069

‎crates/triton-chat-discord/src/gateway.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ impl DiscordGatewayAdapter {
436436
raw_token: String::new(),
437437
trace_id: uuid::Uuid::new_v4().to_string(),
438438
sender_ref: None,
439+
conversation_ref: None,
439440
};
440441
let principal_for_post = principal.clone();
441442
let (tool, args) = route_command(content, &self.inbound_tool);

‎crates/triton-chat-discord/src/lib.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,7 @@ async fn handle_message_component(
625625
raw_token: String::new(),
626626
trace_id: uuid::Uuid::new_v4().to_string(),
627627
sender_ref: None,
628+
conversation_ref: None,
628629
};
629630
let principal_for_post = principal.clone();
630631

@@ -859,6 +860,7 @@ async fn handle_application_command(
859860
raw_token: String::new(),
860861
trace_id: uuid::Uuid::new_v4().to_string(),
861862
sender_ref: None,
863+
conversation_ref: None,
862864
};
863865
let principal_for_post = principal.clone();
864866

@@ -1185,6 +1187,7 @@ async fn handle_modal_submit(
11851187
raw_token: String::new(),
11861188
trace_id: uuid::Uuid::new_v4().to_string(),
11871189
sender_ref: None,
1190+
conversation_ref: None,
11881191
};
11891192
let principal_for_post = principal.clone();
11901193

‎crates/triton-chat-googlechat/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,7 @@ async fn serve_dashboard_png(
621621
raw_token: String::new(),
622622
trace_id: uuid::Uuid::new_v4().to_string(),
623623
sender_ref: None,
624+
conversation_ref: None,
624625
};
625626
return match adapter
626627
.dispatcher
@@ -1271,6 +1272,7 @@ async fn handle_webhook(
12711272
trace_id: uuid::Uuid::new_v4().to_string(),
12721273
// #250: only under `upstream` — see Principal::sender_ref.
12731274
sender_ref: identity_was_resolved_upstream.then(|| sender_name.to_string()),
1275+
conversation_ref: None,
12741276
};
12751277
let principal_for_post = principal.clone();
12761278

‎crates/triton-chat-identity/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ impl UpstreamResolver {
290290
// — so the resolver dispatch is auditable against the id it
291291
// was asked about, not only against the answer it gave.
292292
sender_ref: Some(sender_key.to_string()),
293+
conversation_ref: None,
293294
};
294295
let args = serde_json::json!({ "platform": self.platform, "sender": sender_key });
295296
let dispatch = dispatcher

‎crates/triton-chat-msteams/src/lib.rs‎

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,6 +1282,7 @@ fn make_principal(sub: &str, scopes: &[String], tenant: &str) -> Principal {
12821282
raw_token: String::new(),
12831283
trace_id: uuid::Uuid::new_v4().to_string(),
12841284
sender_ref: None,
1285+
conversation_ref: None,
12851286
}
12861287
}
12871288

@@ -1328,9 +1329,9 @@ async fn dispatch_message(
13281329
pick: None,
13291330
conversation_ref: Some(conversation_reference_json(
13301331
verified.reply_base(),
1331-
activity,
13321332
&conversation_id,
13331333
&recipient_id,
1334+
&sender.from_id,
13341335
&sender.tenant,
13351336
)),
13361337
};
@@ -1649,9 +1650,9 @@ async fn handle_callback(
16491650
.unwrap_or_default();
16501651
let conversation_ref = Some(conversation_reference_json(
16511652
verified.reply_base(),
1652-
activity,
16531653
&conversation_id,
16541654
&bot_id,
1655+
&sender.from_id,
16551656
&sender.tenant,
16561657
));
16571658
let key = triton_chat_routing::ConvKey::msteams(conversation_id, "", sender.sub.clone());
@@ -1822,7 +1823,18 @@ async fn dispatch_and_post_reply(
18221823
// helper is here for when `upstream` lands, where the resolver
18231824
// replaces the asserted identity and the raw id becomes the only
18241825
// way to tell an impersonation from the victim's own session.
1825-
let principal = make_principal(&sender.sub, &sender.scopes, &sender.tenant);
1826+
let mut principal = make_principal(&sender.sub, &sender.scopes, &sender.tenant);
1827+
// T3 (async-ops): carry the Teams conversationReference into the agent's
1828+
// turn so a tool like `start_operation` can persist it and deliver a result
1829+
// back to this conversation later (the in-process embedded dispatch passes
1830+
// the Principal straight to the tool).
1831+
principal.conversation_ref = Some(conversation_reference_json(
1832+
verified.reply_base(),
1833+
conversation_id,
1834+
recipient_id,
1835+
&sender.from_id,
1836+
&sender.tenant,
1837+
));
18261838
let principal_for_post = principal.clone();
18271839
// Direct render_report (the "Open report:" Execute): the chart URL
18281840
// is minted from the INVOKED args — the result carries only a PNG.
@@ -2778,16 +2790,11 @@ const OUTBOUND_TOOL: &str = "outbound";
27782790
/// `recipient`); `service_url` is the JWT-derived trusted reply base.
27792791
fn conversation_reference_json(
27802792
service_url: &str,
2781-
activity: &Activity,
27822793
conversation_id: &str,
27832794
bot_id: &str,
2795+
user_id: &str,
27842796
tenant: &str,
27852797
) -> Value {
2786-
let user_id = activity
2787-
.from
2788-
.as_ref()
2789-
.map(|f| f.id.clone())
2790-
.unwrap_or_default();
27912798
json!({
27922799
// Self-describing: the delivery side (the agent's outbound-callback
27932800
// receiver) routes to the matching triton adapter by this channel.
@@ -2929,13 +2936,33 @@ impl OutboundCourier for MsTeamsAdapter {
29292936
// without them (crew review of #327).
29302937
self.authorize(req, principal).await?;
29312938
let r = Self::parse_outbound_reference(req)?;
2932-
let rendered = text_reply_message(&req.result);
2933-
let body = surface_mapper::build_activity_body(
2939+
// The SAME renderer the inbound reply path uses, not
2940+
// `text_reply_message`.
2941+
//
2942+
// This feature exists to deliver a FINISHED long-running operation
2943+
// back to the conversation that started it, and the archetypal
2944+
// such result asks for approval. `text_reply_message` drops every
2945+
// interactive component, so that prompt arrived as prose with
2946+
// nothing to click — and a render error became the literal text
2947+
// `(no content)` posted with `result: ok` (crew review of #327).
2948+
//
2949+
// `build_reply_body` also binds each control's correlation token
2950+
// to (tenant, recipient), which is what stops a proactively
2951+
// delivered card being a capability for whoever can see it
2952+
// (#250/#287). The recipient here is the stored `user_id`.
2953+
let chrome = fetch_chrome(self, principal).await;
2954+
let body = build_reply_body(
2955+
self,
29342956
&r.bot_id,
29352957
&r.conversation_id,
29362958
&r.user_id,
2937-
&rendered,
2938-
);
2959+
&req.result,
2960+
None,
2961+
principal,
2962+
&chrome,
2963+
&r.tenant_id,
2964+
)
2965+
.await;
29392966
let started = std::time::Instant::now();
29402967
let outcome = post_activity_to(self, &r.service_url, &r.conversation_id, &body).await;
29412968
let latency_ms = started.elapsed().as_millis() as u64;
@@ -3122,20 +3149,13 @@ mod tests {
31223149
/// serviceUrl + tenant round-trip. This is the reference the host persists.
31233150
#[test]
31243151
fn conversation_reference_is_flattened_for_the_courier() {
3125-
let activity: Activity = serde_json::from_value(json!({
3126-
"type": "message",
3127-
"from": { "id": "29:user-alice" },
3128-
"conversation": { "id": "a:conv-1" },
3129-
"recipient": { "id": "28:bot-1" },
3130-
"text": "hi",
3131-
}))
3132-
.expect("activity");
3133-
3152+
// The caller flattens the inbound Activity: recipient (bot) → bot_id,
3153+
// from (user) → user_id.
31343154
let r = conversation_reference_json(
31353155
"https://smba.trafficmanager.net/emea/",
3136-
&activity,
31373156
"a:conv-1",
31383157
"28:bot-1",
3158+
"29:user-alice",
31393159
"tenant-acme",
31403160
);
31413161
assert_eq!(r["channel"], "msteams");

‎crates/triton-chat-signal/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ impl SignalAdapter {
386386
raw_token: String::new(),
387387
trace_id: uuid::Uuid::new_v4().to_string(),
388388
sender_ref: None,
389+
conversation_ref: None,
389390
};
390391
let (tool_name, args) = route_command(body, &self.inbound_tool);
391392
let principal_for_post = principal.clone();

‎crates/triton-chat-telegram/src/lib.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,7 @@ async fn process_update(adapter: Arc<TelegramAdapter>, update: TelegramUpdate) -
991991
trace_id: uuid::Uuid::new_v4().to_string(),
992992
// #250: only under `upstream` — see Principal::sender_ref.
993993
sender_ref: identity_was_resolved_upstream.then(|| sender_key.to_string()),
994+
conversation_ref: None,
994995
};
995996

996997
let chat_id = message.chat.id;
@@ -1253,6 +1254,7 @@ async fn handle_form_outcome(
12531254
trace_id: uuid::Uuid::new_v4().to_string(),
12541255
// #250: see Principal::sender_ref.
12551256
sender_ref: identity_was_resolved_upstream.then(|| sender_key.to_string()),
1257+
conversation_ref: None,
12561258
};
12571259
let principal_for_post = principal.clone();
12581260
dispatch_and_render(
@@ -1515,6 +1517,7 @@ async fn handle_callback_query(
15151517
trace_id: uuid::Uuid::new_v4().to_string(),
15161518
// #250: only under `upstream` — see Principal::sender_ref.
15171519
sender_ref: identity_was_resolved_upstream.then(|| sender_key.to_string()),
1520+
conversation_ref: None,
15181521
};
15191522

15201523
// For private chats the post-back chat_id equals `from.id`;

‎crates/triton-chat-twilio/src/rcs.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ async fn process_message(
358358
raw_token: String::new(),
359359
trace_id: uuid::Uuid::new_v4().to_string(),
360360
sender_ref: None,
361+
conversation_ref: None,
361362
};
362363

363364
let (tool_name, args) = route_command(text, &adapter.inbound_tool);

0 commit comments

Comments
 (0)