From 9d850991d9952ec728d89eb07ef3ae14a9020b2e Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 23:32:28 -0500 Subject: [PATCH] test(evals): prepare owned real Slack endpoint fixtures --- .../external-slack-fixture.test.ts | 17 +++++ .../external-slack-fixture.ts | 71 +++++++++++++++++++ .../external-slack-provider.test.ts | 16 ++++- .../external-slack-provider.ts | 39 ++++++++-- 4 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 tests/runner-e2e/chat-catalog-domains/external-slack-fixture.test.ts create mode 100644 tests/runner-e2e/chat-catalog-domains/external-slack-fixture.ts diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.test.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.test.ts new file mode 100644 index 00000000000..5e3d0b80290 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.test.ts @@ -0,0 +1,17 @@ +import {describe,it,expect} from "vitest"; +import {slackCallbackManifest} from "./external-slack-fixture.js"; +const webhook="https://isolated.example.test/api/chat-webhooks/actual-public-id/slack"; +const original=()=>({oauth_config:{scopes:{bot:["channels:history","chat:write"]}},settings:{event_subscriptions:{request_url:"https://old.example.test/events",bot_events:["message.channels"]},interactivity:{is_enabled:true,request_url:"https://old.example.test/actions"}},features:{slash_commands:[{command:"/owned",url:"https://old.example.test/command"}]}}); +describe("actual disposable Slack app callback preparation",()=>{ + it("updates existing callback surfaces while retaining independent scopes/events and original restore manifest",()=>{ + const prior=original(),copy=structuredClone(prior),next=slackCallbackManifest(prior,webhook); + expect(prior).toEqual(copy);expect(next.oauth_config).toEqual(copy.oauth_config);expect(next.settings.event_subscriptions.bot_events).toEqual(["message.channels"]); + expect(next.settings.event_subscriptions.request_url).toBe(webhook);expect(next.settings.interactivity.request_url).toBe(webhook);expect(next.features.slash_commands[0].url).toBe(webhook); + }); + it("cannot turn an unconfigured app or arbitrary callback URL into a passing Slack fixture",()=>{ + const noEvents=original();noEvents.settings.event_subscriptions.bot_events=[]; + expect(()=>slackCallbackManifest(noEvents,webhook)).toThrow(/already have authorized/); + for(const path of ["http://isolated.example.test/api/chat-webhooks/id/slack","https://elsewhere.example.test/arbitrary-hook","https://isolated.example.test/api/chat-webhooks/id/slack?token=private"]) + expect(()=>slackCallbackManifest(original(),path)).toThrow(); + }); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.ts new file mode 100644 index 00000000000..277940f590a --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.ts @@ -0,0 +1,71 @@ +import type { ChatCatalogContext,ChatSlackIngressReceipt } from "../chat-catalog-contract.js"; +import type { ChatConversation,ChatEndpoint,ChatEndpointResource } from "../../../packages/shared/src/types/chat-channels.js"; +import { selectedExternalBinding,selectedExternalCredential } from "./external-fixtures.js"; +import { slackProvider,assertSlackFixture,slackCompanionFields } from "./external-slack-provider.js"; +import { startSlackRelay } from "./external-slack-relay.js"; +import { pollUntil } from "../api.js"; +import { root } from "./external-support.js"; +export { slackCompanionFields }; +interface Principal {principalId:string;externalDetail:string;paperclipUserId:string|null;status:string} +export interface SlackMessage {ts:string;thread_ts?:string;user?:string;bot_id?:string;text?:string;files?:Array<{id:string;permalink?:string;url_private_download?:string;size?:number}>} +/** Preserve the selected app's actual scopes/events; only point its existing + * callback surfaces at this attempt's signed product webhook. */ +export function slackCallbackManifest(original:Record,webhook:string){ + const url=new URL(webhook);if(url.protocol!=="https:"||!/^\/api\/chat-webhooks\/[a-zA-Z0-9_-]+\/slack$/.test(url.pathname)||url.search||url.hash||url.username||url.password)throw new Error("Disposable Slack callback is not the actual supported HTTPS product route"); + const manifest=structuredClone(original) as Record;manifest.settings??={};manifest.settings.event_subscriptions??={}; + if(!Array.isArray(manifest.settings.event_subscriptions.bot_events)||!manifest.settings.event_subscriptions.bot_events.some((event:string)=>["message.channels","message.groups"].includes(event)))throw new Error("Setup blocker: allowlisted Slack app must already have authorized message event subscriptions; fixture will not silently widen scopes"); + manifest.settings.event_subscriptions.request_url=webhook; + manifest.settings.interactivity={...manifest.settings.interactivity,is_enabled:true,request_url:webhook}; + for(const command of manifest.features?.slash_commands??[])command.url=webhook; + return manifest; +} +export async function prepareSlackFixture(ctx:ChatCatalogContext){ + const binding=selectedExternalBinding(ctx);assertSlackFixture(binding);const p=binding.prerequisites; + if(!ctx.input.privateSecrets)throw new Error("Slack fixture requires private credential redaction"); + const port=Number(p.webhookRelayPort);if(!Number.isInteger(port)||port<1024||port>65535)throw new Error("Setup blocker: dedicated webhookRelayPort for the isolated HTTPS tunnel is required"); + const provider=slackProvider(ctx,binding);let endpoint:ChatEndpoint|undefined,relay:Awaited>|undefined,originalManifest:Record|undefined,manifestChanged=false; + const createdThreads:string[]=[],createdFiles:string[]=[]; + async function cleanup(){ + try{if(endpoint)await ctx.input.api.post(`/api/chat-endpoints/${endpoint.id}/setup`,{action:"remove"});} + finally{try{if(manifestChanged&&originalManifest)await provider.call("configuration","apps.manifest.update",{app_id:p.appId,manifest:originalManifest});}finally{try{await provider.cleanupOwned();}finally{await relay?.close();}}} + } + try{ + await ctx.input.api.patch("/api/instance/settings/experimental",{enableChatConnectors:true}); + endpoint=await ctx.input.api.post(`${root(ctx)}/chat-endpoints`,{provider:"slack",assignedAgentId:ctx.input.fixtures.agent.id,name:"Disposable catalog Slack",slackSetupMethod:"manual"}); + const webhook=endpoint.setup.webhookUrl;if(!webhook||new URL(webhook).origin!==new URL(p.registeredWebhookOrigin!).origin)throw new Error("Setup blocker: actual server PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL must match designated registeredWebhookOrigin before any Slack operation"); + relay=await startSlackRelay({port,endpointPublicId:endpoint.publicId,baseURL:ctx.input.api.baseURL,signingSecret:selectedExternalCredential(ctx,binding,"signingSecretEnv"),workspaceId:p.workspaceId!,senderUserId:p.senderUserId!,channelId:p.channelId!}); + const sender=await provider.call<{ok:true;team_id:string;user_id:string;bot_id?:string}>("sender","auth.test",{}),bot=await provider.call<{ok:true;team_id:string;user_id:string;bot_id?:string}>("bot","auth.test",{}); + if(sender.team_id!==p.workspaceId||sender.user_id!==p.senderUserId||sender.bot_id||bot.team_id!==p.workspaceId||bot.user_id!==p.botUserId||!bot.bot_id)throw new Error("Setup blocker: selected Slack credentials do not identify the designated disposable workspace/human/bot"); + const app=await provider.call<{ok:true;bot:{app_id:string}}>("bot","bots.info",{bot:bot.bot_id});if(app.bot?.app_id!==p.appId)throw new Error("Setup blocker: actual Slack bot token belongs to a different app than the allowlisted disposable app"); + const human=await provider.call<{ok:true;user:{id:string;team_id:string;name:string;is_bot?:boolean;deleted?:boolean}}>("bot","users.info",{user:p.senderUserId});if(human.user.id!==p.senderUserId||human.user.team_id!==p.workspaceId||human.user.is_bot||human.user.deleted||!human.user.name)throw new Error("Setup blocker: designated Slack sender is not the selected active human"); + endpoint=await ctx.input.api.postSensitive(`/api/chat-endpoints/${endpoint.id}/setup`,{action:"configure",credentials:{botToken:selectedExternalCredential(ctx,binding,"botTokenEnv"),signingSecret:selectedExternalCredential(ctx,binding,"signingSecretEnv")}}); + const exported=await provider.call<{ok:true;manifest:Record}>("configuration","apps.manifest.export",{app_id:p.appId});originalManifest=exported.manifest; + if(!originalManifest||typeof originalManifest!=="object")throw new Error("Setup blocker: disposable Slack app manifest could not be independently read"); + const manifest=slackCallbackManifest(originalManifest,webhook); + manifestChanged=true;await provider.call("configuration","apps.manifest.update",{app_id:p.appId,manifest}); + endpoint=await pollUntil({label:"actual Slack signed callback verification",deadlineAt:Date.now()+60000,load:()=>ctx.input.api.get(`/api/chat-endpoints/${endpoint!.id}`),accept:row=>!!row.setup.webhookVerifiedAt}); + endpoint=await ctx.input.api.post(`/api/chat-endpoints/${endpoint.id}/setup`,{action:"verify"}); + await ctx.input.api.patch(`/api/chat-endpoints/${endpoint.id}`,{requireAtMention:false,allowGroupChats:true,allowUnlinkedPeople:false}); + const resources=await ctx.input.api.get(`/api/chat-endpoints/${endpoint.id}/resources`);if(!resources.some(row=>row.providerResourceId===p.channelId&&row.availability==="available"))throw new Error("Setup blocker: app bot is not an actual member of the allowlisted disposable Slack channel"); + await ctx.input.api.putSensitive(`/api/chat-endpoints/${endpoint.id}/resources`,{resources:resources.map(row=>({id:row.id,enabled:row.providerResourceId===p.channelId}))}); + async function postRoot(text:string){const result=await provider.call<{ok:true;channel:string;ts:string;message:{user:string;text:string}}>("sender","chat.postMessage",{channel:p.channelId,text,unfurl_links:false,unfurl_media:false});if(result.channel!==p.channelId||result.message?.user!==p.senderUserId||!/^\d+\.\d{6}$/.test(result.ts))throw new Error("Actual Slack root did not bind the designated human/channel");createdThreads.push(result.ts);return result.ts;} + const threadId=await postRoot(`Disposable launch discussion ${ctx.input.nonce}`); + const principals=await pollUntil({label:"actual unlinked Slack sender discovery",deadlineAt:Date.now()+60000,load:()=>ctx.input.api.get(`/api/chat-endpoints/${endpoint!.id}/principals`),accept:rows=>rows.some(row=>row.externalDetail===`@${human.user.name}`)}); + const principal=principals.filter(row=>row.externalDetail===`@${human.user.name}`);if(principal.length!==1||principal[0]!.status==="linked")throw new Error("Setup blocker: the genuine Slack human must have one unlinked discovered identity in the new endpoint"); + const intent=await ctx.input.api.postSensitive<{confirmationUrl:string}>(`/api/chat-endpoints/${endpoint.id}/principals/${principal[0]!.principalId}/link-intent`,{expiresInSeconds:600}),url=new URL(intent.confirmationUrl,ctx.input.api.baseURL),token=url.searchParams.get("token");if(url.pathname!=="/chat-identity/confirm"||!token)throw new Error("Actual Slack identity link returned an unsupported confirmation surface");ctx.input.privateSecrets.push(token); + // Fixture setup uses the same supported signed-in confirmation route as the + // human UI, through the noninstrumented sensitive transport. The bearer link + // never enters a Test browser URL/trace; this is labelled operator API setup. + await ctx.input.api.postSensitive("/api/chat-identity-links/confirm",{token}); + const linked=(await ctx.input.api.get(`/api/chat-endpoints/${endpoint.id}/principals`)).filter(row=>row.principalId===principal[0]!.principalId&&row.status==="linked");if(linked.length!==1||!linked[0]!.paperclipUserId)throw new Error("Actual signed-in person did not complete the selected Slack identity link"); + endpoint=await ctx.input.api.post(`/api/chat-endpoints/${endpoint.id}/finish`,{});if(endpoint.status!=="active")throw new Error("Setup blocker: supported Slack finish did not activate the actual endpoint"); + return {binding,provider,relay,endpoint,threadId,createdThreads,createdFiles,linkedPaperclipUserId:linked[0]!.paperclipUserId, + async createDistractor(text:string){return postRoot(text);}, + async readThread(rootTs:string){if(!createdThreads.includes(rootTs))throw new Error("Slack egress read escaped an owned thread");const result=await provider.call<{ok:true;messages:SlackMessage[];has_more?:boolean}>("bot","conversations.replies",{channel:p.channelId,ts:rootTs,limit:100});if(result.has_more||!Array.isArray(result.messages))throw new Error("Slack thread exceeds bounded fixture observation");return result.messages;}, + async resolveIngress(query:string,messageId:string,providerCalls:number):Promise{ + const conversations=await pollUntil({label:"actual Slack canonical source persistence",deadlineAt:Date.now()+60000,load:()=>ctx.input.api.get(`/api/chat-endpoints/${endpoint!.id}/conversations`),accept:rows=>rows.some(row=>row.externalThreadId===`slack:${p.channelId}:${threadId}`)}),conversation=conversations.find(row=>row.externalThreadId===`slack:${p.channelId}:${threadId}`)!; + const comments=await ctx.input.api.get>(`/api/issues/${conversation.issueId}/comments?order=asc`),humanComments=comments.filter(row=>row.body===query&&row.authorUserId===linked[0]!.paperclipUserId&&row.metadata?.sourceChannel==="slack"); + if(!humanComments.length)throw new Error("Canonical Slack input did not persist under the linked human identity");return {query,endpointId:endpoint!.id,conversationId:conversation.id,issueId:conversation.issueId,humanCommentId:humanComments.at(-1)!.id,linkedPaperclipUserId:linked[0]!.paperclipUserId!,providerCalls,provider:{workspaceId:p.workspaceId!,channelId:p.channelId!,senderUserId:p.senderUserId!,threadId,canonicalMessageId:messageId}}; + },cleanup}; + }catch(error){await cleanup();throw error;} +} diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-provider.test.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-provider.test.ts index bc5d24885c6..0d931468d47 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-slack-provider.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-provider.test.ts @@ -1,7 +1,7 @@ import { describe,expect,it } from "vitest"; import type { ChatConversation,ChatEndpoint,ExternalChannelBindingSummary } from "../../../packages/shared/src/types/chat-channels.js"; import { assertSlackCall,assertSlackFixture,slackIngressMatches,type SlackIngressReceipt } from "./external-slack-provider.js"; -function binding(){return {disposable:true as const,resourceIds:["TWORKSPACE","CCHANNEL","USENDER","UBOT","AAPP"],credentialEnv:["PAPERCLIP_CHAT_EVAL_SLACK_BOT","PAPERCLIP_CHAT_EVAL_SLACK_SENDER","PAPERCLIP_CHAT_EVAL_SLACK_SIGNING","PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"],prerequisites:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",senderUserId:"USENDER",botUserId:"UBOT",appId:"AAPP",registeredWebhookOrigin:"https://isolated.example.test",botTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_BOT",senderTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SENDER",signingSecretEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SIGNING",configurationTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"}};} +function binding(){return {disposable:true as const,resourceIds:["TWORKSPACE","CCHANNEL","USENDER","UBOT","AAPP"],credentialEnv:["PAPERCLIP_CHAT_EVAL_SLACK_BOT","PAPERCLIP_CHAT_EVAL_SLACK_SENDER","PAPERCLIP_CHAT_EVAL_SLACK_SIGNING","PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"],prerequisites:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",senderUserId:"USENDER",botUserId:"UBOT",appId:"AAPP",registeredWebhookOrigin:"https://isolated.example.test",webhookRelayPort:"3399",botTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_BOT",senderTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SENDER",signingSecretEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SIGNING",configurationTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"}};} function ingress(){const receipt:SlackIngressReceipt={providerCalls:1,query:"Exact canonical query.",endpointId:"endpoint",conversationId:"conversation",issueId:"issue",humanCommentId:"comment",linkedPaperclipUserId:"human",provider:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",threadId:"1793050000.123456",canonicalMessageId:"1793050001.123456",senderUserId:"USENDER"}};return {receipt,endpoint:{id:"endpoint",companyId:"company",assignedAgentId:"ceo",provider:"slack",providerAccountId:"TWORKSPACE",botExternalId:"UBOT"} as ChatEndpoint,conversation:{id:"conversation",endpointId:"endpoint",companyId:"company",issueId:"issue",externalConversationId:"slack:CCHANNEL",externalThreadId:"slack:CCHANNEL:1793050000.123456"} as ChatConversation,binding:{endpointId:"endpoint",conversationId:"conversation",provider:"slack",assignedAgentLocked:true} as ExternalChannelBindingSummary,comment:{id:"comment",body:"Exact canonical query.",authorUserId:"human",metadata:{sourceChannel:"slack"}},canonicalQuery:"Exact canonical query.",companyId:"company",agentId:"ceo"};} describe("finite real Slack companion prerequisites and source binding",()=>{ it("requires each explicit disposable provider resource and a distinct actual human sender",()=>{ @@ -26,3 +26,17 @@ describe("finite real Slack companion prerequisites and source binding",()=>{ ]){const i=ingress();mutate(i);expect(slackIngressMatches(i)).toBe(false);} }); }); + +it("cleans only messages proven created by this exact private client; arbitrary delete remains unavailable",async()=>{ + const {slackProvider}=await import("./external-slack-provider.js"); + const b=binding(),requests:Array<{method:string;body:Record}>=[]; + const ctx={input:{privateSecrets:[],privateInputs:{read:()=>"synthetic-private-token"}}} as unknown as import("../chat-catalog-contract.js").ChatCatalogContext; + const client=slackProvider(ctx,b,(async(url,options)=>{ + const method=String(url).split("/").at(-1)!,body=JSON.parse(String(options?.body));requests.push({method,body}); + return new Response(JSON.stringify(method==="chat.postMessage"?{ok:true,channel:"CCHANNEL",ts:"1793050002.123456"}:method==="conversations.replies"?{ok:true,messages:[{ts:"1793050003.123456",thread_ts:"1793050002.123456",user:"UBOT"},{ts:"1793050004.123456",thread_ts:"1793050002.123456",user:"UOTHER"}]}:{ok:true}),{status:200}); + }) as typeof fetch); + await expect(client.call("sender","chat.delete",{channel:"CCHANNEL",ts:"1793059999.123456"})).rejects.toThrow(/finite/); + await client.call("sender","chat.postMessage",{channel:"CCHANNEL",text:"Actual disposable creation"}); + await client.cleanupOwned();await client.cleanupOwned(); + expect(requests).toEqual([{method:"chat.postMessage",body:{channel:"CCHANNEL",text:"Actual disposable creation"}},{method:"conversations.replies",body:{channel:"CCHANNEL",ts:"1793050002.123456",limit:100}},{method:"chat.delete",body:{channel:"CCHANNEL",ts:"1793050003.123456"}},{method:"chat.delete",body:{channel:"CCHANNEL",ts:"1793050002.123456"}}]); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-provider.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-provider.ts index 3c65dbe7207..c57d28469fd 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-slack-provider.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-provider.ts @@ -2,8 +2,8 @@ import type { ChatCatalogContext } from "../chat-catalog-contract.js"; import type { ChatConversation,ChatEndpoint,ExternalChannelBindingSummary } from "../../../packages/shared/src/types/chat-channels.js"; import { selectedExternalCredential,type ProviderBinding } from "./external-fixtures.js"; -export const slackCompanionFields=["workspaceId","channelId","senderUserId","botUserId","appId","registeredWebhookOrigin","botTokenEnv","senderTokenEnv","signingSecretEnv","configurationTokenEnv"] as const; -const methods={bot:new Set(["auth.test","conversations.info","conversations.replies","conversations.history","chat.postMessage","files.info"]),sender:new Set(["auth.test","chat.postMessage","files.getUploadURLExternal","files.completeUploadExternal"]),configuration:new Set(["apps.manifest.export","apps.manifest.update"])}; +export const slackCompanionFields=["workspaceId","channelId","senderUserId","botUserId","appId","registeredWebhookOrigin","webhookRelayPort","botTokenEnv","senderTokenEnv","signingSecretEnv","configurationTokenEnv"] as const; +const methods={bot:new Set(["auth.test","users.info","bots.info","conversations.info","conversations.replies","conversations.history","chat.postMessage","files.info"]),sender:new Set(["auth.test","chat.postMessage","files.getUploadURLExternal","files.completeUploadExternal"]),configuration:new Set(["apps.manifest.export","apps.manifest.update"])}; export function assertSlackFixture(binding:ProviderBinding){ const p=binding.prerequisites; for(const field of slackCompanionFields)if(!p[field])throw new Error(`Setup blocker: Slack ${field} reference is required`); @@ -23,16 +23,43 @@ export function assertSlackCall(binding:ProviderBinding,kind:keyof typeof method /** Official Slack HTTP only. No fixture call occurs during import/calibration. */ export function slackProvider(ctx:ChatCatalogContext,binding:ProviderBinding,fetcher:typeof fetch=fetch){ assertSlackFixture(binding);let calls=0;const receipts:Array<{kind:string;method:string;ok:boolean}>=[]; - return {receipts,async call>(kind:keyof typeof methods,method:string,parameters:Record,signal?:AbortSignal):Promise{ - assertSlackCall(binding,kind,method,parameters);if(++calls>40)throw new Error("Slack fixture exceeded its declared bounded provider calls"); + const ownedMessages:Array<{kind:"sender"|"bot";ts:string}>=[],ownedFiles:string[]=[],ownedThreads=new Set(); + async function invoke>(kind:keyof typeof methods,method:string,parameters:Record,signal?:AbortSignal):Promise{ + if(++calls>120)throw new Error("Slack fixture exceeded its declared bounded provider calls"); const token=selectedExternalCredential(ctx,binding,kind==="bot"?"botTokenEnv":kind==="sender"?"senderTokenEnv":"configurationTokenEnv"); const response=await fetcher(`https://slack.com/api/${method}`,{method:"POST",redirect:"error",signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000),headers:{authorization:`Bearer ${token}`,"content-type":"application/json; charset=utf-8"},body:JSON.stringify(parameters)}); if(!response.ok){receipts.push({kind,method,ok:false});await response.body?.cancel();throw new Error(`Slack fixture ${method} returned HTTP ${response.status}; private response withheld`);} - const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length>2*1024*1024)throw new Error("Slack fixture response exceeds bounded metadata size"); - let result:T;try{result=JSON.parse(bytes.toString("utf8")) as T;}catch{throw new Error("Slack fixture returned invalid metadata; response withheld");} + const chunks:Uint8Array[]=[];let size=0;const reader=response.body?.getReader();if(!reader)throw new Error("Slack fixture response is missing"); + try{for(;;){const part=await reader.read();if(part.done)break;size+=part.value.byteLength;if(size>2*1024*1024)throw new Error("Slack fixture response exceeds bounded metadata size");chunks.push(part.value);}}finally{await reader.cancel();} + let result:T;try{result=JSON.parse(Buffer.concat(chunks).toString("utf8")) as T;}catch{throw new Error("Slack fixture returned invalid metadata; response withheld");} receipts.push({kind,method,ok:result.ok===true});if(result.ok!==true)throw new Error(`Slack fixture ${method} was not accepted; provider error details withheld`);return result; + } + return {receipts,async call>(kind:keyof typeof methods,method:string,parameters:Record,signal?:AbortSignal):Promise{ + assertSlackCall(binding,kind,method,parameters);const result=await invoke(kind,method,parameters,signal); + if(method==="chat.postMessage"&&kind!=="configuration"&&result.channel===binding.prerequisites.channelId&&typeof result.ts==="string"){ownedMessages.push({kind,ts:result.ts});ownedThreads.add(typeof parameters.thread_ts==="string"?parameters.thread_ts:result.ts);} + if(method==="files.completeUploadExternal"&&kind==="sender"&&Array.isArray(result.files))for(const file of result.files){if(file&&typeof file.id==="string")ownedFiles.push(file.id);} + return result; + }, + /** No caller-supplied deletion IDs: only receipts from this client's actual + * successful creation calls may be removed. Existing channel content is never + * enumerated for cleanup or adopted as owned. */ + async cleanupOwned(){ + const failures:string[]=[]; + // Product-published replies are owned only through this client's genuinely + // created roots. Read each exact thread, then delete only this app's bot + // messages; never adopt another author's content as disposable. + const threads=[...ownedThreads];ownedThreads.clear(); + for(const ts of threads)try{ + const thread=await invoke<{ok:true;messages:Array<{ts:string;thread_ts?:string;user?:string}>;has_more?:boolean}>("bot","conversations.replies",{channel:binding.prerequisites.channelId,ts,limit:100}); + if(thread.has_more)throw new Error("Owned Slack cleanup exceeds finite thread bound"); + for(const message of thread.messages??[])if(message.user===binding.prerequisites.botUserId&&message.thread_ts===ts)await invoke("bot","chat.delete",{channel:binding.prerequisites.channelId,ts:message.ts}); + }catch{failures.push("owned-thread-replies");} + for(const file of ownedFiles.splice(0).reverse())try{await invoke("sender","files.delete",{file});}catch{failures.push("owned-file");} + for(const message of ownedMessages.splice(0).reverse())try{await invoke(message.kind,"chat.delete",{channel:binding.prerequisites.channelId,ts:message.ts});}catch{failures.push("owned-message");} + if(failures.length)throw new Error(`Slack disposable cleanup incomplete: ${failures.length} owned resources could not be removed`); }}; } + export interface SlackIngressReceipt {providerCalls:number;query:string;endpointId:string;conversationId:string;issueId:string;humanCommentId:string;linkedPaperclipUserId:string;provider:{workspaceId:string;channelId:string;threadId:string;canonicalMessageId:string;senderUserId:string}} /** Source-qualified public DTO checks; externalThreadId is the SDK encoded key. */ export function slackIngressMatches(input:{receipt:SlackIngressReceipt;endpoint:ChatEndpoint;conversation:ChatConversation;binding:ExternalChannelBindingSummary;comment:{id:string;body:string;authorUserId:string|null;metadata?:{sourceChannel?:string}};canonicalQuery:string;companyId:string;agentId:string}){