diff --git a/tests/runner-e2e/chat-catalog-domains/external-fixtures.md b/tests/runner-e2e/chat-catalog-domains/external-fixtures.md index ba7596b5ad7..afa32a3925d 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-fixtures.md +++ b/tests/runner-e2e/chat-catalog-domains/external-fixtures.md @@ -214,3 +214,25 @@ persisted human comments comprise the two original-query turns and the product's two human-attributed proposal-resolution notices. Approval proves the exact saved reference and consumer, with no Morgan task run or task-delivery event; it never proves that a later task already consumed or validated the value. + +The `connect-slack` and `channel-prerequisites` journeys additionally select +`slackBrowserSessionEnv`, a private reference containing Playwright storage-state +JSON for the designated disposable **human** Slack account. It is separate from +the sender API token. Cookies/storage may belong only to official HTTPS Slack +hosts. The finite isolated browser verifies the selected workspace/channel URL +and copies the current person's actual member ID from their own profile before +submitting exactly the saved app's slash command with `connect`. Missing or +changed controls, a different member, an existing draft, expired sign-in or an +unavailable designated session fail that prerequisite; no API-created slash event +substitutes for it. The provider-signed callback and current processed +`lastConnectAt` are independently required before the real ownership button, +Continue to message test and Done controls. The final case also requires a new +actual two-way native conversation; credential saving or endpoint activation +alone does not pass. These callbacks are authored and calibrated offline, not +live-qualified Slack/browser evidence. + +The private session JSON, each cookie/storage value, bot token and signing secret +remain in the selected private envelope, redactor, process memory and private +0600 child handoff. They never enter model input, public case state, argv, +subprocess environment, trace, video, screenshots or closed receipts. The child +handoff is removed unconditionally. Supply no unrelated browser sessions. diff --git a/tests/runner-e2e/chat-catalog-domains/external-fixtures.ts b/tests/runner-e2e/chat-catalog-domains/external-fixtures.ts index 23d7f127b33..95476cf4192 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-fixtures.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-fixtures.ts @@ -26,8 +26,8 @@ export const externalRequirements: Record (input.mode === "grok-routine-configure" ? 16384 : 8192) || !["secure-key", "canary", "gateway-token-rotation", "company-secret-create", "personal-secret-create", "grok-routine-configure", "slack-identity-confirm"].includes(input.mode)) throw new Error("Invalid private browser request"); + if (!input.credential || input.credential.length > (input.mode === "grok-routine-configure" ? 16384 : 8192) || !["secure-key", "canary", "gateway-token-rotation", "company-secret-create", "personal-secret-create", "grok-routine-configure", "slack-identity-confirm", "slack-endpoint-configure", "slack-wizard-confirm"].includes(input.mode)) throw new Error("Invalid private browser request"); const base = new URL(input.baseURL); if (base.hostname !== "127.0.0.1" || base.protocol !== "http:" || new URL(input.setupURL).origin !== base.origin) throw new Error("Private browser target is outside the disposable loopback instance"); const browser = await chromium.launch({ headless: true, ...(input.executablePath ? { executablePath: input.executablePath } : {}), ...(input.channel ? { channel: input.channel } : {}) }); @@ -23,6 +24,48 @@ async function main() { if (await field.inputValue() !== input.credential) throw new Error("Canary entry failed"); return { processIsolation: true, testRunnerAttached: false, credentialEntered: true, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false }; } + if(input.mode === "slack-wizard-confirm"){ + const identity=input.slackIdentity; + if(!validSlackWizardIdentityInput(input.companyId,identity,input.setupURL)||!identity)throw new Error("Invalid finite normal Slack wizard identity operation"); + const session=await context.request.get("/api/auth/get-session");if(!session.ok()||(await session.json()).user?.id!==identity.userId)throw new Error("Slack wizard requires the exact same signed-in person"); + const endpointPath=`/api/chat-endpoints/${identity.endpointId}`; + const before=await context.request.get(endpointPath);if(!before.ok())throw new Error("Slack wizard endpoint missing");const endpoint=await before.json();if(endpoint.companyId!==input.companyId||endpoint.provider!=="slack"||endpoint.status!=="verifying"||endpoint.setup?.step!=="test")throw new Error("Normal Slack wizard is not waiting at actual verified identity/test boundary"); + const principals=await context.request.get(`${endpointPath}/principals`);if(!principals.ok())throw new Error("Slack command discovery unavailable"); + const rows=await principals.json() as Array<{principalId:string;externalDetail:string;lastConnectAt?:string;status:string;paperclipUserId?:string}>;const candidate=rows.filter(row=>row.principalId===identity.principalId&&row.externalDetail===identity.externalDetail&&row.lastConnectAt&&Date.parse(row.lastConnectAt)>=Date.parse(endpoint.setup.testStartedAt));if(candidate.length!==1||candidate[0]!.status==="linked")throw new Error("Genuine current Slack connect receipt is required before wizard ownership confirmation"); + await page.goto(input.setupURL,{waitUntil:"domcontentloaded"}); + const intentResponse=page.waitForResponse(response=>new URL(response.url()).pathname===`${endpointPath}/principals/${identity.principalId}/link-intent`&&response.request().method()==="POST"&&response.status()===201); + const confirmed=page.waitForResponse(response=>new URL(response.url()).pathname==="/api/chat-identity-links/confirm"&&response.request().method()==="POST"&&response.ok()); + await page.getByRole("button",{name:`Link ${identity.externalLabel} to my Paperclip account`,exact:true}).click(); + const intent=await(await intentResponse).json(),token=new URL(intent.confirmationUrl,base.origin).searchParams.get("token"),confirmation=await confirmed; + if(!token||confirmation.request().postDataJSON()?.token!==token||(await confirmation.json()).endpointId!==identity.endpointId)throw new Error("Actual Slack ownership button did not confirm its own generated scoped link"); + await page.getByText("Linked to you",{exact:true}).waitFor(); + const linkedResponse=await context.request.get(`${endpointPath}/principals`);if(!linkedResponse.ok()||!(await linkedResponse.json()).some((row:{principalId:string;status:string;paperclipUserId?:string})=>row.principalId===identity.principalId&&row.status==="linked"&&row.paperclipUserId===identity.userId))throw new Error("Wizard ownership did not persist as the exact same person"); + await page.getByRole("button",{name:"Continue to message test",exact:true}).click(); + const activation=page.waitForResponse(response=>new URL(response.url()).pathname===`${endpointPath}/finish`&&response.request().method()==="POST"&&response.ok()); + await page.getByRole("button",{name:"Done",exact:true}).click();const activated=await(await activation).json(); + const afterResponse=await context.request.get(endpointPath);if(!afterResponse.ok())throw new Error("Activated Slack endpoint unavailable");const after=await afterResponse.json(); + if(activated.id!==identity.endpointId||activated.status!=="active"||after.status!=="active"||after.companyId!==input.companyId||after.setup?.step!=="complete")throw new Error("Actual wizard Done did not independently save activation"); + return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,endpointId:identity.endpointId,principalId:identity.principalId,paperclipUserId:identity.userId,identityConfirmed:true,identityUiVisible:true,humanSubmittedSecurely:true,activationUiVisible:true,endpointActive:true}; + } + if(input.mode === "slack-endpoint-configure"){ + const slack=input.slackSetup; + if(!validSlackSetupPrivate(input.companyId,slack,input.setupURL)||!slack||!input.companyId||!input.credential.startsWith("xoxb-"))throw new Error("Invalid finite existing Slack credential form"); + const endpointPath=`/api/chat-endpoints/${slack.endpointId}`,beforeResponse=await context.request.get(endpointPath); + if(!beforeResponse.ok())throw new Error("Owned Slack draft unavailable"); + const before=await beforeResponse.json(); + if(before.companyId!==input.companyId||before.provider!=="slack"||before.assignedAgentId!==slack.agentId||before.setup?.slackSetupMethod!=="existing"||before.providerAccountId)throw new Error("Slack credential form must target the exact fresh existing-app draft"); + await page.goto(input.setupURL,{waitUntil:"domcontentloaded"}); + const bot=page.getByLabel("Bot User OAuth Token",{exact:true}),secret=page.getByLabel("Signing Secret",{exact:true}); + await bot.waitFor();if(await bot.getAttribute("type")!=="password"||await secret.getAttribute("type")!=="password")throw new Error("Slack credential fields are not protected"); + await bot.fill(input.credential);await secret.fill(slack.signingSecret); + const saved=page.waitForResponse(response=>new URL(response.url()).pathname===`${endpointPath}/setup`&&response.request().method()==="POST"&&response.ok()); + await page.getByRole("button",{name:"Connect Slack app",exact:true}).click();const response=await saved,submitted=response.request().postDataJSON(); + if(submitted.action!=="configure"||submitted.credentials?.botToken!==input.credential||submitted.credentials?.signingSecret!==slack.signingSecret)throw new Error("Slack UI submitted another private setup operation"); + const result=await response.json(),afterResponse=await context.request.get(endpointPath);if(!afterResponse.ok())throw new Error("Saved Slack endpoint unavailable");const after=await afterResponse.json(); + if(result.id!==slack.endpointId||after.id!==slack.endpointId||after.companyId!==input.companyId||after.assignedAgentId!==slack.agentId||after.providerAccountId!==slack.workspaceId||!after.setup?.webhookSecretConfigured)throw new Error("Slack UI credential save did not persist the intended real workspace/agent"); + await bot.waitFor({state:"hidden"});const storageClean=await page.evaluate(values=>!values.some(value=>JSON.stringify([Object.entries(localStorage),Object.entries(sessionStorage),document.body.innerText,location.href]).includes(value)),[input.credential,slack.signingSecret]); + return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,endpointId:slack.endpointId,slackCredentialUiVisible:true,humanSubmittedSecurely:true,storageClean}; + } if (input.mode === "slack-identity-confirm") { const identity = input.slackIdentity; if (!validSlackIdentityInput(input.companyId, identity, input.setupURL) || !identity || !input.companyId) throw new Error("Invalid finite Slack confirmation operation"); diff --git a/tests/runner-e2e/chat-catalog-domains/external-private-browser.ts b/tests/runner-e2e/chat-catalog-domains/external-private-browser.ts index 02555b29725..7cea6fd11a5 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-private-browser.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-private-browser.ts @@ -7,8 +7,9 @@ import { promisify } from "node:util"; import { chromium, type Cookie } from "@playwright/test"; import type { GrokRoutinePrivateInput } from "./external-grok-private-contract.js"; import type { SlackIdentityPrivateInput } from "./external-slack-identity-contract.js"; -export interface PrivateBrowserReceipt { processIsolation: true; testRunnerAttached: false; tracingEnabled: false; videoEnabled: false; apiStepRecorder: false; credentialEntered?: boolean; connectionId?: string; challengeVisible?: boolean; passwordInput?: boolean; autocompleteOff?: boolean; humanSubmittedSecurely?: boolean; visibleReady?: boolean; storageClean?: boolean; newTokenId?: string; tokenUiVisible?: boolean; newWorkedBeforeRevocation?: boolean; oldWorkedUntilRevocation?: boolean; oldRevoked?: boolean; newWorksAfter?: boolean; oldStatusAfter?: number; otherWorksAfter?: boolean; secretId?:string; secretUiVisible?:boolean; bindingId?:string; routineConfigured?:boolean; routineUiVisible?:boolean; identityConfirmed?:boolean; identityUiVisible?:boolean; principalId?:string; paperclipUserId?:string } -export async function privateBrowserEntry(input: { mode: "secure-key" | "canary" | "gateway-token-rotation" | "company-secret-create" | "personal-secret-create" | "grok-routine-configure" | "slack-identity-confirm"; slackIdentity?:SlackIdentityPrivateInput; grok?:GrokRoutinePrivateInput; companyId?:string; personal?:{definitionId:string;userId:string}; gateway?: { id: string; oldTokenId: string; otherToken: string; endpointPath: string; readName: string }; baseURL: string; setupURL: string; providerURL: string; credential: string; cookies: Cookie[] }, onToken?: (token: { id: string; token: string }) => void) { +import type { SlackSetupPrivateInput } from "./external-slack-setup-private-contract.js"; +export interface PrivateBrowserReceipt { processIsolation: true; testRunnerAttached: false; tracingEnabled: false; videoEnabled: false; apiStepRecorder: false; credentialEntered?: boolean; connectionId?: string; challengeVisible?: boolean; passwordInput?: boolean; autocompleteOff?: boolean; humanSubmittedSecurely?: boolean; visibleReady?: boolean; storageClean?: boolean; newTokenId?: string; tokenUiVisible?: boolean; newWorkedBeforeRevocation?: boolean; oldWorkedUntilRevocation?: boolean; oldRevoked?: boolean; newWorksAfter?: boolean; oldStatusAfter?: number; otherWorksAfter?: boolean; secretId?:string; secretUiVisible?:boolean; bindingId?:string; routineConfigured?:boolean; routineUiVisible?:boolean; identityConfirmed?:boolean; identityUiVisible?:boolean; principalId?:string; paperclipUserId?:string; endpointId?:string; slackCredentialUiVisible?:boolean; activationUiVisible?:boolean; endpointActive?:boolean } +export async function privateBrowserEntry(input: { mode: "secure-key" | "canary" | "gateway-token-rotation" | "company-secret-create" | "personal-secret-create" | "grok-routine-configure" | "slack-identity-confirm" | "slack-endpoint-configure" | "slack-wizard-confirm"; slackSetup?:SlackSetupPrivateInput; slackIdentity?:SlackIdentityPrivateInput; grok?:GrokRoutinePrivateInput; companyId?:string; personal?:{definitionId:string;userId:string}; gateway?: { id: string; oldTokenId: string; otherToken: string; endpointPath: string; readName: string }; baseURL: string; setupURL: string; providerURL: string; credential: string; cookies: Cookie[] }, onToken?: (token: { id: string; token: string }) => void) { const directory = await mkdtemp(path.join(tmpdir(), "paperclip-private-browser-")); try { const request = path.join(directory, "request.json"); @@ -23,7 +24,9 @@ export async function privateBrowserEntry(input: { mode: "secure-key" | "canary" } catch { throw new Error("Private browser entry failed; child output and credential-bearing request withheld"); } let receipt: PrivateBrowserReceipt; try { receipt = JSON.parse(output) as PrivateBrowserReceipt; } catch { throw new Error("Private browser returned invalid closed receipt"); } if (receipt.processIsolation !== true || receipt.testRunnerAttached !== false || receipt.tracingEnabled !== false || receipt.videoEnabled !== false || receipt.apiStepRecorder !== false - || Object.keys(receipt).some(key => !["processIsolation", "testRunnerAttached", "tracingEnabled", "videoEnabled", "apiStepRecorder", "credentialEntered", "connectionId", "challengeVisible", "passwordInput", "autocompleteOff", "humanSubmittedSecurely", "visibleReady", "storageClean", "newTokenId", "tokenUiVisible", "newWorkedBeforeRevocation", "oldWorkedUntilRevocation", "oldRevoked", "newWorksAfter", "oldStatusAfter", "otherWorksAfter", "secretId", "secretUiVisible", "bindingId", "routineConfigured", "routineUiVisible", "identityConfirmed", "identityUiVisible", "principalId", "paperclipUserId"].includes(key))) throw new Error("Private browser receipt exceeds its closed contract"); + || Object.keys(receipt).some(key => !["processIsolation", "testRunnerAttached", "tracingEnabled", "videoEnabled", "apiStepRecorder", "credentialEntered", "connectionId", "challengeVisible", "passwordInput", "autocompleteOff", "humanSubmittedSecurely", "visibleReady", "storageClean", "newTokenId", "tokenUiVisible", "newWorkedBeforeRevocation", "oldWorkedUntilRevocation", "oldRevoked", "newWorksAfter", "oldStatusAfter", "otherWorksAfter", "secretId", "secretUiVisible", "bindingId", "routineConfigured", "routineUiVisible", "identityConfirmed", "identityUiVisible", "principalId", "paperclipUserId", "endpointId", "slackCredentialUiVisible", "activationUiVisible", "endpointActive"].includes(key))) throw new Error("Private browser receipt exceeds its closed contract"); + if (input.mode === "slack-wizard-confirm" && (receipt.endpointId !== input.slackIdentity?.endpointId || receipt.principalId !== input.slackIdentity?.principalId || receipt.paperclipUserId !== input.slackIdentity?.userId || receipt.identityConfirmed !== true || receipt.activationUiVisible !== true || receipt.endpointActive !== true)) throw new Error("Invalid closed Slack wizard completion receipt"); + if (input.mode === "slack-endpoint-configure" && (receipt.endpointId !== input.slackSetup?.endpointId || receipt.slackCredentialUiVisible !== true || receipt.humanSubmittedSecurely !== true || receipt.storageClean !== true)) throw new Error("Invalid closed Slack credential form receipt"); if (input.mode === "slack-identity-confirm" && (receipt.identityConfirmed !== true || receipt.identityUiVisible !== true || receipt.principalId !== input.slackIdentity?.principalId || receipt.paperclipUserId !== input.slackIdentity?.userId)) throw new Error("Invalid closed Slack identity confirmation receipt"); if (input.mode === "grok-routine-configure" && (receipt.bindingId !== input.grok?.bindingId || receipt.routineConfigured !== true || receipt.routineUiVisible !== true || receipt.visibleReady !== undefined)) throw new Error("Invalid closed Grok routine configuration receipt"); if (input.mode === "gateway-token-rotation") { diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.ts index 322c272f20a..6cc70f28d78 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-fixture.ts @@ -1,85 +1,697 @@ -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 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 { + privateBrowserEntry, + type PrivateBrowserReceipt, +} from "./external-private-browser.js"; +import { privateSlackHumanConnect } from "./external-slack-human.js"; +import type { SlackHumanConnectReceipt } from "./external-slack-human-contract.js"; import { root } from "./external-support.js"; export { slackCompanionFields }; -interface Principal {principalId:string;externalLabel: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}>} +interface Principal { + principalId: string; + externalLabel: string; + externalDetail: string; + paperclipUserId: string | null; + status: string; + lastConnectAt?: string | null; +} +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 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, options:{unlinkBeforeCanonical?:boolean}={}){ - 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 priorExperiments=await ctx.input.api.get<{enableChatConnectors?:boolean}>("/api/instance/settings/experimental"); - 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{try{await relay?.close();}finally{await ctx.input.api.patch("/api/instance/settings/experimental",{enableChatConnectors:priorExperiments.enableChatConnectors===true});}}}} - } - 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"); - let linkedPaperclipUserId: string|null=linked[0]!.paperclipUserId; - const principalId=principal[0]!.principalId; - if(options.unlinkBeforeCanonical){ - await ctx.input.api.delete(`/api/chat-endpoints/${endpoint.id}/principals/${principalId}/link`); - const current=(await ctx.input.api.get(`/api/chat-endpoints/${endpoint.id}/principals`)).filter(row=>row.principalId===principalId); - if(current.length!==1||current[0]!.status==="linked")throw new Error("Public unlink did not revoke the exact qualified sender"); - linkedPaperclipUserId=null; +export async function prepareSlackFixture( + ctx: ChatCatalogContext, + options: { + unlinkBeforeCanonical?: boolean; + normalWizard?: { endpointId: string; agentId: string }; + } = {}, +) { + 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 priorExperiments = await ctx.input.api.get<{ + enableChatConnectors?: boolean; + }>("/api/instance/settings/experimental"); + const provider = slackProvider(ctx, binding); + let endpoint: ChatEndpoint | undefined, + relay: Awaited> | undefined, + originalManifest: Record | undefined, + manifestChanged = false; + const createdThreads: string[] = [], + createdFiles: string[] = []; + let credentialUiReceipt: PrivateBrowserReceipt | undefined, + identityUiReceipt: PrivateBrowserReceipt | undefined, + humanCommandReceipt: SlackHumanConnectReceipt | undefined; + 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 { + try { + await relay?.close(); + } finally { + await ctx.input.api.patch("/api/instance/settings/experimental", { + enableChatConnectors: + priorExperiments.enableChatConnectors === true, + }); + } + } + } + } + } + try { + await ctx.input.api.patch("/api/instance/settings/experimental", { + enableChatConnectors: true, + }); + if (options.normalWizard) { + endpoint = await ctx.input.api.get( + `/api/chat-endpoints/${options.normalWizard.endpointId}`, + ); + if ( + endpoint.companyId !== ctx.input.fixtures.company.id || + endpoint.provider !== "slack" || + endpoint.assignedAgentId !== options.normalWizard.agentId || + endpoint.setup.slackSetupMethod !== "existing" || + endpoint.providerAccountId + ) + throw new Error( + "Normal Slack setup must use the exact human-created fresh existing-app draft", + ); + } else + 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", + ); + if (options.normalWizard) { + credentialUiReceipt = await privateBrowserEntry({ + mode: "slack-endpoint-configure", + companyId: ctx.input.fixtures.company.id, + slackSetup: { + endpointId: endpoint.id, + agentId: options.normalWizard.agentId, + workspaceId: p.workspaceId!, + signingSecret: selectedExternalCredential( + ctx, + binding, + "signingSecretEnv", + ), + }, + baseURL: ctx.input.api.baseURL, + providerURL: ctx.input.api.baseURL, + setupURL: `${ctx.input.api.baseURL}/${ctx.input.fixtures.company.issuePrefix}/apps/chat/connect?provider=slack&purpose=chat&resume=${endpoint.id}&stage=credentials`, + credential: selectedExternalCredential(ctx, binding, "botTokenEnv"), + cookies: await ctx.input.page.context().cookies(ctx.input.api.baseURL), + }); + endpoint = await ctx.input.api.get( + `/api/chat-endpoints/${endpoint.id}`, + ); + } else + 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, + }); + if (options.normalWizard) { + const page = await ctx.input.page.context().newPage(); + try { + await page.goto( + `${ctx.input.api.baseURL}/${ctx.input.fixtures.company.issuePrefix}/apps/chat/connect?provider=slack&purpose=chat&resume=${endpoint.id}`, + { waitUntil: "domcontentloaded" }, + ); + await page + .getByRole("heading", { + name: "Verify Slack connection", + exact: true, + }) + .waitFor(); + const verification = page.waitForResponse( + (response) => + new URL(response.url()).pathname === + `/api/chat-endpoints/${endpoint!.id}/setup` && + response.request().method() === "POST" && + response.ok(), + ); + await page + .getByRole("button", { name: "Continue", exact: true }) + .click(); + const response = await verification; + if (response.request().postDataJSON()?.action !== "verify") + throw new Error("Slack verification UI submitted another action"); + endpoint = (await response.json()) as ChatEndpoint; + } finally { + await page.close(); + } + } else + 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", + ); + if (options.normalWizard) { + const command = endpoint.setup.command; + if ( + typeof command !== "string" || + !/^\/[a-zA-Z0-9_-]{1,100}$/.test(command) || + !( + originalManifest.features as + { slash_commands?: Array<{ command: string }> } | undefined + )?.slash_commands?.some((row) => row.command === command) + ) + throw new Error( + "Setup blocker: actual saved app command does not match the selected installed Slack app", + ); + const selectedChannel = await provider.call<{ + ok: true; + channel: { id: string; name: string }; + }>("bot", "conversations.info", { channel: p.channelId }); + if ( + selectedChannel.channel.id !== p.channelId || + !/^[a-z0-9_-]{1,80}$/.test(selectedChannel.channel.name) + ) + throw new Error( + "Setup blocker: selected Slack channel identity is unavailable for independent human UI verification", + ); + const startedAt = Date.now(); + relay.expectHumanConnect(command); + humanCommandReceipt = await privateSlackHumanConnect( + { + workspaceId: p.workspaceId!, + channelId: p.channelId!, + channelName: selectedChannel.channel.name, + senderUserId: p.senderUserId!, + command, + sessionJson: selectedExternalCredential( + ctx, + binding, + "slackBrowserSessionEnv", + ), + }, + ctx.input.privateSecrets, + ); + await pollUntil({ + label: "genuine Slack-signed human connect command callback", + deadlineAt: Date.now() + 60000, + load: async () => relay!.receipt(), + accept: (row) => row.humanConnectCaptured, + }); + await pollUntil({ + label: "actual processed current Slack connect discovery", + deadlineAt: Date.now() + 60000, + load: () => + ctx.input.api.get( + `/api/chat-endpoints/${endpoint!.id}/principals`, + ), + accept: (rows) => + rows.some( + (row) => + row.principalId === principal[0]!.principalId && + !!row.lastConnectAt && + Date.parse(row.lastConnectAt) >= startedAt - 1000, + ), + }); + const session = await ctx.input.api.get<{ user: { id: string } }>( + "/api/auth/get-session", + ); + identityUiReceipt = await privateBrowserEntry({ + mode: "slack-wizard-confirm", + companyId: ctx.input.fixtures.company.id, + slackIdentity: { + endpointId: endpoint.id, + principalId: principal[0]!.principalId, + userId: session.user.id, + externalDetail: principal[0]!.externalDetail, + externalLabel: principal[0]!.externalLabel, + }, + baseURL: ctx.input.api.baseURL, + providerURL: ctx.input.api.baseURL, + setupURL: `${ctx.input.api.baseURL}/${ctx.input.fixtures.company.issuePrefix}/apps/chat/connect?provider=slack&purpose=chat&resume=${endpoint.id}`, + credential: "finite-slack-normal-wizard-confirmation", + cookies: await ctx.input.page.context().cookies(ctx.input.api.baseURL), + }); + } else { + 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 = options.normalWizard + ? await ctx.input.api.get( + `/api/chat-endpoints/${endpoint.id}`, + ) + : 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", + ); + let linkedPaperclipUserId: string | null = linked[0]!.paperclipUserId; + const principalId = principal[0]!.principalId; + if (options.unlinkBeforeCanonical) { + await ctx.input.api.delete( + `/api/chat-endpoints/${endpoint.id}/principals/${principalId}/link`, + ); + const current = ( + await ctx.input.api.get( + `/api/chat-endpoints/${endpoint.id}/principals`, + ) + ).filter((row) => row.principalId === principalId); + if (current.length !== 1 || current[0]!.status === "linked") + throw new Error( + "Public unlink did not revoke the exact qualified sender", + ); + linkedPaperclipUserId = null; + } + return { + binding, + provider, + relay, + endpoint, + threadId, + createdThreads, + createdFiles, + principalId, + credentialUiReceipt, + identityUiReceipt, + humanCommandReceipt, + senderIdentity: { + userId: human.user.id, + workspaceId: human.user.team_id, + handle: human.user.name, + }, + get linkedPaperclipUserId() { + return linkedPaperclipUserId; + }, + async readPrincipal() { + const rows = await ctx.input.api.get( + `/api/chat-endpoints/${endpoint!.id}/principals`, + ); + const matches = rows.filter((row) => row.principalId === principalId); + if (matches.length !== 1) + throw new Error( + "Exact Slack principal is no longer uniquely inspectable", + ); + return matches[0]!; + }, + async refreshLinkedIdentity() { + const rows = await ctx.input.api.get( + `/api/chat-endpoints/${endpoint!.id}/principals`, + ), + current = rows.filter( + (row) => + row.principalId === principalId && + row.status === "linked" && + !!row.paperclipUserId, + ); + if (current.length !== 1) + throw new Error( + "Actual Slack principal has no current confirmed identity", + ); + linkedPaperclipUserId = current[0]!.paperclipUserId; + return linkedPaperclipUserId!; + }, + 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, + rootTs = threadId, + ): Promise { + if (!createdThreads.includes(rootTs)) + throw new Error( + "Slack ingress resolution escaped an owned actual root", + ); + if (!linkedPaperclipUserId) + throw new Error( + "Slack source cannot resolve before actual identity confirmation", + ); + 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}:${rootTs}`, + ), + }), + conversation = conversations.find( + (row) => row.externalThreadId === `slack:${p.channelId}:${rootTs}`, + )!; + const comments = await ctx.input.api.get< + Array<{ + id: string; + body: string; + authorUserId: string; + metadata?: { sourceChannel?: string }; + }> + >(`/api/issues/${conversation.issueId}/comments?order=asc`), + humanComments = comments.filter( + (row) => + row.body === query && + row.authorUserId === linkedPaperclipUserId && + 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: linkedPaperclipUserId!, + providerCalls, + provider: { + workspaceId: p.workspaceId!, + channelId: p.channelId!, + senderUserId: p.senderUserId!, + threadId: rootTs, + canonicalMessageId: messageId, + }, + }; + }, + cleanup, + }; + } catch (error) { + await cleanup(); + throw error; } - return {binding,provider,relay,endpoint,threadId,createdThreads,createdFiles,principalId,senderIdentity:{userId:human.user.id,workspaceId:human.user.team_id,handle:human.user.name}, - get linkedPaperclipUserId(){return linkedPaperclipUserId;}, - async readPrincipal(){const rows=await ctx.input.api.get(`/api/chat-endpoints/${endpoint!.id}/principals`);const matches=rows.filter(row=>row.principalId===principalId);if(matches.length!==1)throw new Error("Exact Slack principal is no longer uniquely inspectable");return matches[0]!;}, - async refreshLinkedIdentity(){const rows=await ctx.input.api.get(`/api/chat-endpoints/${endpoint!.id}/principals`),current=rows.filter(row=>row.principalId===principalId&&row.status==="linked"&&!!row.paperclipUserId);if(current.length!==1)throw new Error("Actual Slack principal has no current confirmed identity");linkedPaperclipUserId=current[0]!.paperclipUserId;return linkedPaperclipUserId!;}, - 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,rootTs=threadId):Promise{ - if(!createdThreads.includes(rootTs))throw new Error("Slack ingress resolution escaped an owned actual root"); - if(!linkedPaperclipUserId)throw new Error("Slack source cannot resolve before actual identity confirmation"); - 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}:${rootTs}`)}),conversation=conversations.find(row=>row.externalThreadId===`slack:${p.channelId}:${rootTs}`)!; - const comments=await ctx.input.api.get>(`/api/issues/${conversation.issueId}/comments?order=asc`),humanComments=comments.filter(row=>row.body===query&&row.authorUserId===linkedPaperclipUserId&&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:linkedPaperclipUserId!,providerCalls,provider:{workspaceId:p.workspaceId!,channelId:p.channelId!,senderUserId:p.senderUserId!,threadId:rootTs,canonicalMessageId:messageId}}; - },cleanup}; - }catch(error){await cleanup();throw error;} } diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-human-contract.test.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-human-contract.test.ts new file mode 100644 index 00000000000..ccb39a5b987 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-human-contract.test.ts @@ -0,0 +1,80 @@ +import { expect, it } from "vitest"; +import { + validSlackHumanConnect, + validSlackHumanReceipt, +} from "./external-slack-human-contract.js"; +const input = { + workspaceId: "T123", + channelId: "C123", + channelName: "owned-test-channel", + senderUserId: "U123", + command: "/owned-agent", + session: { + cookies: [ + { + name: "d", + value: "synthetic-private-session", + domain: ".slack.com", + path: "/", + secure: true, + }, + ], + origins: [ + { + origin: "https://app.slack.com", + localStorage: [{ name: "session", value: "synthetic-private-storage" }], + }, + ], + }, +}; +it("admits only a bounded official Slack session and exact named workspace/person connect command", () => { + expect(validSlackHumanConnect(input)).toBe(true); + for (const mutate of [ + (x: typeof input) => { + x.workspaceId = "other"; + }, + (x: typeof input) => { + x.senderUserId = "UBOT\nother"; + }, + (x: typeof input) => { + x.command = "/owned-agent delete"; + }, + (x: typeof input) => { + x.session.cookies[0]!.domain = "slack.com.attacker.invalid"; + }, + (x: typeof input) => { + x.session.cookies[0]!.secure = false; + }, + (x: typeof input) => { + x.session.origins[0]!.origin = "http://app.slack.com"; + }, + (x: typeof input) => { + x.session.cookies = []; + }, + ]) { + const bad = structuredClone(input); + mutate(bad); + expect(validSlackHumanConnect(bad)).toBe(false); + } +}); +it("requires independently copied exact human ID and closed isolated browser receipt", () => { + const r = { + processIsolation: true, + testRunnerAttached: false, + tracingEnabled: false, + videoEnabled: false, + verifiedWorkspaceId: input.workspaceId, + verifiedHumanId: input.senderUserId, + commandSubmitted: true, + }; + expect(validSlackHumanReceipt(r, input)).toBe(true); + for (const wrong of [ + { ...r, verifiedHumanId: "another" }, + { ...r, verifiedWorkspaceId: "TOTHER" }, + { ...r, commandSubmitted: false }, + { ...r, testRunnerAttached: true }, + { ...r, tracingEnabled: true }, + { ...r, session: "unexpected" }, + ]) + expect(validSlackHumanReceipt(wrong, input)).toBe(false); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-human-contract.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-human-contract.ts new file mode 100644 index 00000000000..0e72a208244 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-human-contract.ts @@ -0,0 +1,101 @@ +import type { PublicSignInStorage as StorageState } from "./external-public-signin-contract.js"; +export interface SlackHumanConnectInput { + workspaceId: string; + channelId: string; + channelName: string; + senderUserId: string; + command: string; + session: StorageState; +} +export function validSlackHumanConnect(input: SlackHumanConnectInput) { + if ( + !/^T[A-Z0-9]+$/.test(input.workspaceId) || + !/^C[A-Z0-9]+$|^G[A-Z0-9]+$/.test(input.channelId) || + !/^[a-z0-9_-]{1,80}$/.test(input.channelName) || + !/^[UW][A-Z0-9]+$/.test(input.senderUserId) || + !/^\/[a-zA-Z0-9_-]{1,100}$/.test(input.command) || + !input.session || + !Array.isArray(input.session.cookies) || + !input.session.cookies.length || + input.session.cookies.length > 100 || + !Array.isArray(input.session.origins) || + input.session.origins.length > 10 + ) + return false; + return ( + input.session.cookies.every( + (cookie) => + !!cookie && + typeof cookie.name === "string" && + cookie.name.length > 0 && + cookie.name.length <= 200 && + typeof cookie.value === "string" && + cookie.value.length > 0 && + cookie.value.length <= 65536 && + cookie.secure === true && + /^\.?([a-z0-9-]+\.)*slack\.com$/i.test(cookie.domain) && + typeof cookie.path === "string" && + cookie.path.startsWith("/"), + ) && + input.session.origins.every((origin) => { + try { + const u = new URL(origin.origin); + return ( + u.protocol === "https:" && + /^([a-z0-9-]+\.)*slack\.com$/i.test(u.hostname) && + u.origin === origin.origin && + !u.username && + !u.password && + !u.port && + Array.isArray(origin.localStorage) && + origin.localStorage.length <= 200 && + origin.localStorage.every( + (item) => + typeof item.name === "string" && + item.name.length <= 1000 && + typeof item.value === "string" && + item.value.length <= 262144, + ) + ); + } catch { + return false; + } + }) + ); +} +export interface SlackHumanConnectReceipt { + processIsolation: true; + testRunnerAttached: false; + tracingEnabled: false; + videoEnabled: false; + verifiedWorkspaceId: string; + verifiedHumanId: string; + commandSubmitted: true; +} +export function validSlackHumanReceipt( + value: unknown, + input: Pick, +) { + const r = value as Partial | undefined; + return ( + !!r && + Object.keys(r).every((key) => + [ + "processIsolation", + "testRunnerAttached", + "tracingEnabled", + "videoEnabled", + "verifiedWorkspaceId", + "verifiedHumanId", + "commandSubmitted", + ].includes(key), + ) && + r.processIsolation === true && + r.testRunnerAttached === false && + r.tracingEnabled === false && + r.videoEnabled === false && + r.verifiedWorkspaceId === input.workspaceId && + r.verifiedHumanId === input.senderUserId && + r.commandSubmitted === true + ); +} diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-human-worker.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-human-worker.ts new file mode 100644 index 00000000000..2b70bf05660 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-human-worker.ts @@ -0,0 +1,123 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { chromium, test } from "@playwright/test"; +import { + validSlackHumanConnect, + type SlackHumanConnectInput, +} from "./external-slack-human-contract.js"; +/** No Playwright Test worker/report/artifact listener is attached. Never emits + * screenshots, trace, cookies, browser state, profile or provider response data. */ +async function main() { + let attached = false; + try { + test.info(); + attached = true; + } catch { + /* standalone */ + } + if (attached) + throw new Error("Private Slack operation cannot run within Test"); + const input = JSON.parse( + await readFile(process.argv[2]!, "utf8"), + ) as SlackHumanConnectInput & { executablePath?: string; channel?: string }; + if (!validSlackHumanConnect(input)) + throw new Error("Invalid finite Slack human operation"); + const browser = await chromium.launch({ + headless: true, + ...(input.executablePath ? { executablePath: input.executablePath } : {}), + ...(input.channel ? { channel: input.channel } : {}), + }); + try { + const storage = path.join(path.dirname(process.argv[2]!), "storage.json"); + await writeFile(storage, JSON.stringify(input.session), { mode: 0o600 }); + const context = await browser.newContext({ storageState: storage }); + await context.grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "https://app.slack.com", + }); + const page = await context.newPage(); + page.setDefaultTimeout(20000); + await page.goto( + `https://app.slack.com/client/${input.workspaceId}/${input.channelId}`, + { waitUntil: "domcontentloaded" }, + ); + const target = () => { + const url = new URL(page.url()); + return ( + url.origin === "https://app.slack.com" && + url.pathname === `/client/${input.workspaceId}/${input.channelId}` + ); + }; + if (!target()) + throw new Error( + "Slack browser did not retain the exact selected workspace/channel", + ); + // These are actual user controls, not a display-name identity assertion. The + // copied member ID must equal independent official auth.test/users.info IDs. + await page + .getByRole("button", { + name: /^(Your profile|Your profile and preferences|Profile and preferences)$/i, + }) + .click(); + await page + .getByRole("menuitem", { name: /^(Profile|View profile)$/i }) + .click(); + const panel = page + .locator('[data-qa="flexpane"], [role="complementary"], [role="dialog"]') + .filter({ + has: page.getByRole("button", { name: /^More(?: actions)?$/i }), + }); + if ((await panel.count()) !== 1) + throw new Error( + "Slack own-profile controls are not uniquely inspectable", + ); + await panel.getByRole("button", { name: /^More(?: actions)?$/i }).click(); + await page.evaluate(() => navigator.clipboard.writeText("")); + await page.getByRole("menuitem", { name: /^Copy member ID$/i }).click(); + const actualUserId = await page.evaluate(() => + navigator.clipboard.readText(), + ); + if (actualUserId !== input.senderUserId || !target()) + throw new Error( + "Actual signed-in Slack human does not match selected provider identity", + ); + await page.keyboard.press("Escape"); + const composer = page.getByRole("textbox", { + name: new RegExp(`^Message #?${input.channelName}$`, "i"), + }); + if ((await composer.count()) !== 1) + throw new Error( + "Slack named channel composer is unavailable or ambiguous", + ); + if ((await composer.innerText()).trim()) + throw new Error( + "Slack composer has an existing draft; finite fixture will not overwrite it", + ); + const command = `${input.command} connect`; + await composer.fill(command); + if ((await composer.innerText()).trim() !== command || !target()) + throw new Error( + "Slack composer differs from the exact saved app connect command", + ); + await composer.press("Enter"); + await composer.filter({ hasText: command }).waitFor({ state: "hidden" }); + return { + processIsolation: true, + testRunnerAttached: false, + tracingEnabled: false, + videoEnabled: false, + verifiedWorkspaceId: input.workspaceId, + verifiedHumanId: actualUserId, + commandSubmitted: true, + }; + } finally { + await browser.close(); + } +} +main() + .then((receipt) => process.stdout.write(JSON.stringify(receipt))) + .catch(() => { + process.stderr.write( + "Private Slack human operation failed; details withheld\n", + ); + process.exitCode = 1; + }); diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-human.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-human.ts new file mode 100644 index 00000000000..6c4150efe9a --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-human.ts @@ -0,0 +1,122 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { chromium } from "@playwright/test"; +import { + validSlackHumanConnect, + validSlackHumanReceipt, + type SlackHumanConnectInput, + type SlackHumanConnectReceipt, +} from "./external-slack-human-contract.js"; +/** This finite child performs one genuine named Slack human command. It neither + * synthesizes a Slack event nor qualifies Paperclip's resulting connection. */ +export async function privateSlackHumanConnect( + input: Omit & { sessionJson: string }, + privateSecrets: string[], +) { + if ( + !input.sessionJson || + Buffer.byteLength(input.sessionJson) > 2 * 1024 * 1024 + ) + throw new Error( + "Setup blocker: bounded private selected Slack human browser session is required", + ); + let session: SlackHumanConnectInput["session"]; + try { + session = JSON.parse(input.sessionJson); + } catch { + throw new Error( + "Setup blocker: selected Slack browser state is not valid private JSON", + ); + } + privateSecrets.push(input.sessionJson); + const request = { + workspaceId: input.workspaceId, + channelId: input.channelId, + channelName: input.channelName, + senderUserId: input.senderUserId, + command: input.command, + session, + }; + if (!validSlackHumanConnect(request)) + throw new Error( + "Setup blocker: selected private Slack session/command is outside the designated official human workspace contract", + ); + for (const cookie of session?.cookies ?? []) + if (typeof cookie.value === "string" && cookie.value) + privateSecrets.push(cookie.value); + for (const origin of session?.origins ?? []) + for (const item of origin.localStorage ?? []) + if (typeof item.value === "string" && item.value) + privateSecrets.push(item.value); + const directory = await mkdtemp( + path.join(tmpdir(), "paperclip-slack-human-"), + ); + try { + const channel = process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL, + requestFile = path.join(directory, "request.json"); + await writeFile( + requestFile, + JSON.stringify({ + ...request, + executablePath: + process.env.PAPERCLIP_RUNNER_E2E_CHROMIUM_EXECUTABLE ?? + (channel ? undefined : chromium.executablePath()), + channel, + }), + { mode: 0o600 }, + ); + let output: string; + try { + output = ( + await promisify(execFile)( + process.execPath, + [ + "--import", + fileURLToPath( + new URL( + "../../../server/node_modules/tsx/dist/loader.mjs", + import.meta.url, + ), + ), + fileURLToPath( + new URL("./external-slack-human-worker.ts", import.meta.url), + ), + requestFile, + ], + { + timeout: 120000, + maxBuffer: 8192, + env: { + PATH: process.env.PATH ?? "", + HOME: directory, + TMPDIR: directory, + NODE_ENV: "test", + DO_NOT_TRACK: "1", + }, + }, + ) + ).stdout; + } catch { + throw new Error( + "Setup blocker: genuine Slack human browser session/control/member identity did not complete the finite connect command; private child diagnostics withheld", + ); + } + let receipt: unknown; + try { + receipt = JSON.parse(output); + } catch { + throw new Error("Slack human command returned an invalid closed receipt"); + } + if (!validSlackHumanReceipt(receipt, request)) + throw new Error( + "Slack human command did not prove the exact actual workspace/person", + ); + return receipt as SlackHumanConnectReceipt; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-identity-contract.test.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-identity-contract.test.ts index 5cb6e0343fb..11974cd5706 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-slack-identity-contract.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-identity-contract.test.ts @@ -50,3 +50,16 @@ it("requires actual preview company, endpoint, Slack provider and independently ]) expect(matchingSlackIdentityPreview(wrong, companyId, input)).toBe(false); }); + +import { validSlackWizardIdentityInput } from "./external-slack-identity-contract.js"; +it("restricts normal wizard continuation to the same real identity and exact saved Slack setup", () => { + const setup = `http://127.0.0.1:3100/ACORN/apps/chat/connect?provider=slack&purpose=chat&resume=${input.endpointId}`; + expect(validSlackWizardIdentityInput(companyId, input, setup)).toBe(true); + for (const wrong of [ + setup + "&stage=test", + setup.replace(input.endpointId, companyId), + setup.replace("slack", "discord"), + setup + "#test", + ]) + expect(validSlackWizardIdentityInput(companyId, input, wrong)).toBe(false); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-identity-contract.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-identity-contract.ts index b808834afad..45b6a1d6e50 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-slack-identity-contract.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-identity-contract.ts @@ -55,3 +55,29 @@ export function matchingSlackIdentityPreview( row.canConfirm !== false ); } + +export function validSlackWizardIdentityInput( + companyId: unknown, + input: SlackIdentityPrivateInput | undefined, + setupURL: string, +) { + try { + const url = new URL(setupURL); + return ( + !!input && + validSlackIdentityInput( + companyId, + input, + `${url.origin}/apps/chat/${input.endpointId}/access`, + ) && + url.pathname.endsWith("/apps/chat/connect") && + url.searchParams.size === 3 && + url.searchParams.get("provider") === "slack" && + url.searchParams.get("purpose") === "chat" && + url.searchParams.get("resume") === input.endpointId && + !url.hash + ); + } catch { + return false; + } +} diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-relay.test.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-relay.test.ts index 5760d040b02..2bd932d0324 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-slack-relay.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-relay.test.ts @@ -13,3 +13,13 @@ describe("genuine Slack-signature preserving disposable relay",()=>{ ]){const i=signed();mutate(i);expect(verifiedSlackRelayRequest(i)).toBe(false);} }); }); + +import {slackHumanConnectPayload} from "./external-slack-relay.js"; +it("binds a genuine signed form's connect meaning to the exact selected human/workspace/channel/command",()=>{ + const expected={workspaceId:"T123",senderUserId:"U123",channelId:"C123",command:"/owned-agent"}; + const payload={team_id:"T123",user_id:"U123",channel_id:"C123",command:"/owned-agent",text:"connect"}; + const body=(value:typeof payload)=>Buffer.from(new URLSearchParams(value).toString()); + expect(slackHumanConnectPayload(body(payload),expected)).toBe(true); + for(const wrong of [{...payload,user_id:"UOTHER"},{...payload,team_id:"TOTHER"},{...payload,channel_id:"COTHER"},{...payload,command:"/unrelated"},{...payload,text:"delete"}])expect(slackHumanConnectPayload(body(wrong),expected)).toBe(false); + expect(slackHumanConnectPayload(Buffer.from(new URLSearchParams(payload).toString()+"&user_id=UOTHER"),expected)).toBe(false); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-relay.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-relay.ts index b85eb372c2c..8d55dbf40d8 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-slack-relay.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-relay.ts @@ -14,10 +14,14 @@ export function verifiedSlackRelayRequest(input:{pathname:string;expectedPath:st * emulator. Only genuine Slack-signed bytes for one actual endpoint are * forwarded. The one captured canonical delivery stays private in this closure * and may be replayed once to prove product idempotency. */ +export function slackHumanConnectPayload(body:Buffer,expected:{workspaceId:string;senderUserId:string;channelId:string;command:string}){ + const form=new URLSearchParams(body.toString("utf8")); + return ["team_id","user_id","channel_id","command","text"].every(key=>form.getAll(key).length===1)&&form.get("team_id")===expected.workspaceId&&form.get("user_id")===expected.senderUserId&&form.get("channel_id")===expected.channelId&&form.get("command")===expected.command&&form.get("text")==="connect"; +} export async function startSlackRelay(input:{port:number;endpointPublicId:string;baseURL:string;signingSecret:string;workspaceId:string;senderUserId:string;channelId:string}){ if(!Number.isInteger(input.port)||input.port<1024||input.port>65535||!/^[a-zA-Z0-9_-]+$/.test(input.endpointPublicId)||!input.signingSecret)throw new Error("Setup blocker: a designated disposable Slack webhook relay port and signing reference are required"); const target=new URL(input.baseURL);if(target.origin!==input.baseURL||target.hostname!=="127.0.0.1"||target.protocol!=="http:")throw new Error("Slack relay target must be the isolated loopback application"); - const expectedPath=`/api/chat-webhooks/${input.endpointPublicId}/slack`;let capture:Captured|undefined,expectedQuery:string|undefined,expectedThread:string|undefined,replayed=false;let forwarded=0,attempts=0; + const expectedPath=`/api/chat-webhooks/${input.endpointPublicId}/slack`;let capture:Captured|undefined,expectedQuery:string|undefined,expectedThread:string|undefined,replayed=false;let forwarded=0,attempts=0;let humanCommand:string|undefined,humanConnectCaptured=false; async function forward(body:Buffer,headers:Record,retry=false){ if(++attempts>80)throw new Error("Slack relay exceeded its bounded callback count"); const response=await fetch(new URL(expectedPath,target),{method:"POST",redirect:"error",signal:AbortSignal.timeout(20000),headers:{...headers,...retry?{"x-slack-retry-num":"1","x-slack-retry-reason":"http_timeout"}:{}},body:new Uint8Array(body)}); @@ -29,6 +33,10 @@ export async function startSlackRelay(input:{port:number;endpointPublicId:string const chunks:Buffer[]=[];let size=0;for await(const chunk of request){size+=chunk.length;if(size>2*1024*1024)throw new Error("Webhook exceeds bounds");chunks.push(Buffer.from(chunk));}const body=Buffer.concat(chunks); if(!verifiedSlackRelayRequest({pathname:request.url,expectedPath,body,headers:request.headers,signingSecret:input.signingSecret})){response.writeHead(403);response.end();return;} const headers={"content-type":request.headers["content-type"]??"application/json","x-slack-signature":String(request.headers["x-slack-signature"]),"x-slack-request-timestamp":String(request.headers["x-slack-request-timestamp"])}; + if(headers["content-type"].split(";")[0]?.trim()==="application/x-www-form-urlencoded"){ + if(humanCommand&&slackHumanConnectPayload(body,{workspaceId:input.workspaceId,senderUserId:input.senderUserId,channelId:input.channelId,command:humanCommand}))humanConnectCaptured=true; + const result=await forward(body,headers);forwarded++;response.writeHead(result.status,{"content-type":result.contentType});response.end(result.bytes);return; + } const payload=JSON.parse(body.toString("utf8")) as {type?:string;team_id?:string;event_id?:string;event?:{type?:string;user?:string;channel?:string;thread_ts?:string;ts?:string;text?:string}}; if(payload.type==="event_callback"&&payload.team_id!==input.workspaceId){response.writeHead(403);response.end();return;} const event=payload.event;if(expectedQuery&&payload.type==="event_callback"&&typeof payload.event_id==="string"&&event?.user===input.senderUserId&&event.channel===input.channelId&&event.text===expectedQuery&&(event.thread_ts??event.ts)===expectedThread){ @@ -38,8 +46,10 @@ export async function startSlackRelay(input:{port:number;endpointPublicId:string }catch{response.writeHead(502);response.end("Disposable Slack relay failed; private details withheld");} }); server.listen(input.port,"127.0.0.1");await once(server,"listening"); - return {expectedPath,expectCanonical(query:string,threadId:string){if(!query||capture||!/^\d+\.\d{6}$/.test(threadId))throw new Error("Slack relay capture has an invalid finite canonical phase");expectedQuery=query;expectedThread=threadId;}, - receipt(){return {forwarded,canonicalDeliveryCaptured:!!capture,duplicateReplayed:replayed};}, + return {expectedPath, + expectHumanConnect(command:string){if(humanCommand||!/^\/[a-zA-Z0-9_-]{1,100}$/.test(command))throw new Error("Exactly one finite saved Slack human connect command is allowed");humanCommand=command;}, + expectCanonical(query:string,threadId:string){if(!query||capture||!/^\d+\.\d{6}$/.test(threadId))throw new Error("Slack relay capture has an invalid finite canonical phase");expectedQuery=query;expectedThread=threadId;}, + receipt(){return {forwarded,canonicalDeliveryCaptured:!!capture,duplicateReplayed:replayed,humanConnectCaptured};}, async replayCanonicalOnce(){if(!capture||replayed)throw new Error("Exactly one genuine captured canonical Slack delivery may be replayed");replayed=true;const result=await forward(capture.body,capture.headers,true);return {status:result.status,providerEventCaptured:true};}, nextFilePhase(){if(!capture)throw new Error("Cannot advance before a genuine canonical Slack event");capture=undefined;expectedQuery=undefined;expectedThread=undefined;replayed=false;}, async close(){capture=undefined;expectedQuery=undefined;server.closeAllConnections();await new Promise((resolve,reject)=>server.close(error=>error?reject(new Error("Slack relay cleanup failed")):resolve()));}, diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-setup-private-contract.test.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-setup-private-contract.test.ts new file mode 100644 index 00000000000..5ee94d96817 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-setup-private-contract.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from "vitest"; +import { validSlackSetupPrivate } from "./external-slack-setup-private-contract.js"; +it("restricts private Slack credential entry to the exact owned existing-app draft", () => { + const company = "11111111-1111-4111-8111-111111111111", + input = { + endpointId: "22222222-2222-4222-8222-222222222222", + agentId: "33333333-3333-4333-8333-333333333333", + workspaceId: "T123", + signingSecret: "synthetic-private-signing", + }, + url = `http://127.0.0.1:3100/ACORN/apps/chat/connect?provider=slack&purpose=chat&resume=${input.endpointId}&stage=credentials`; + expect(validSlackSetupPrivate(company, input, url)).toBe(true); + for (const wrong of [ + url.replace("slack", "discord"), + url.replace(input.endpointId, company), + url + "&reconnect=1", + url.replace("credentials", "test"), + url + "#other", + ]) + expect(validSlackSetupPrivate(company, input, wrong)).toBe(false); + expect( + validSlackSetupPrivate( + company, + { ...input, signingSecret: "bad\nsecret" }, + url, + ), + ).toBe(false); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-setup-private-contract.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-setup-private-contract.ts new file mode 100644 index 00000000000..d2bb722a2e8 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-setup-private-contract.ts @@ -0,0 +1,36 @@ +export interface SlackSetupPrivateInput { + endpointId: string; + agentId: string; + workspaceId: string; + signingSecret: string; +} +export function validSlackSetupPrivate( + companyId: unknown, + input: SlackSetupPrivateInput | undefined, + setupURL: string, +) { + try { + const url = new URL(setupURL); + return ( + typeof companyId === "string" && + /^[a-f0-9-]{36}$/i.test(companyId) && + !!input && + [input.endpointId, input.agentId].every((id) => + /^[a-f0-9-]{36}$/i.test(id), + ) && + /^T[A-Z0-9]+$/.test(input.workspaceId) && + !!input.signingSecret && + input.signingSecret.length <= 8192 && + !/[\r\n]/.test(input.signingSecret) && + url.pathname.endsWith("/apps/chat/connect") && + url.searchParams.get("provider") === "slack" && + url.searchParams.get("purpose") === "chat" && + url.searchParams.get("resume") === input.endpointId && + url.searchParams.get("stage") === "credentials" && + url.searchParams.size === 4 && + !url.hash + ); + } catch { + return false; + } +} diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-setup.test.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-setup.test.ts new file mode 100644 index 00000000000..a28ce008c88 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-setup.test.ts @@ -0,0 +1,262 @@ +import { expect, it } from "vitest"; +import { gradeSlackSetup, slackSetupGuidance } from "./external-slack-setup.js"; +function valid() { + const run = (id: string, agentId: string, issueId: string) => ({ + id, + agentId, + companyId: "company", + runtimeMode: "native", + status: "succeeded", + contextSnapshot: { issueId }, + }), + query = + "Please say your exact Paperclip agent name and confirm you can reply in this same Slack conversation. Do not start a separate task."; + const source = { + query, + endpointId: "endpoint", + conversationId: "conversation", + issueId: "slack-source", + humanCommentId: "human", + linkedPaperclipUserId: "person", + providerCalls: 1, + provider: { + workspaceId: "T123", + channelId: "C123", + senderUserId: "U123", + threadId: "1000000000.000001", + canonicalMessageId: "1000000000.000003", + }, + }; + return { + scenarioId: "connection-setup.channel-prerequisites", + companyId: "company", + leadId: "ceo", + origin: "http://127.0.0.1:3100", + reply: "Slack is ready; you can talk to Morgan there.", + state: { + represented: { id: "morgan", name: "Morgan" }, + noEndpointsBefore: true, + providerEnabledBefore: true, + initialRunId: "initial", + canonicalIssueId: "canonical", + agentEndpointCreateStatus: 403, + createdByHumanUi: true, + workspaceId: "T123", + senderUserId: "U123", + botUserId: "UBOT", + currentHumanId: "person", + }, + phaseReplies: [ + { + runId: "initial", + body: "You must connect Slack through supported setup and select Morgan. Confirm your account before chatting.", + }, + { runId: "final", body: "Slack is ready; you can talk to Morgan there." }, + ], + runs: [ + run("initial", "ceo", "canonical"), + run("probe", "morgan", "slack-source"), + run("final", "ceo", "canonical"), + ], + humanQueries: [ + "Let me talk to Morgan from Slack.", + "Let me talk to Morgan from Slack.", + ], + tasks: [], + projects: [], + goals: [], + documents: [], + revisions: [], + comments: [], + activity: [], + links: [], + connections: [], + profiles: [], + secrets: [], + endpoints: [{ id: "endpoint" }], + interactions: [], + slackSetup: { + endpoint: { + id: "endpoint", + companyId: "company", + provider: "slack", + assignedAgentId: "morgan", + providerAccountId: "T123", + status: "active", + setup: { + step: "complete", + webhookVerifiedAt: "2031-02-14T10:00:00Z", + testStartedAt: null, + }, + activatedAt: "2031-02-14T10:02:00Z", + allowUnlinkedPeople: false, + }, + credentialUi: { + endpointId: "endpoint", + slackCredentialUiVisible: true, + humanSubmittedSecurely: true, + storageClean: true, + }, + identityUi: { + endpointId: "endpoint", + identityConfirmed: true, + identityUiVisible: true, + activationUiVisible: true, + endpointActive: true, + paperclipUserId: "person", + processIsolation: true, + testRunnerAttached: false, + }, + humanCommand: { + commandSubmitted: true, + verifiedWorkspaceId: "T123", + verifiedHumanId: "U123", + }, + signedConnect: true, + lastConnectAt: "2031-02-14T10:01:00Z", + principalStatus: "linked", + principalUserId: "person", + source, + sourceBound: true, + sourceRunId: "probe", + sourceReply: { + id: "reply", + createdByRunId: "probe", + authorAgentId: "morgan", + body: "I am Morgan, replying in this same Slack conversation.", + }, + messages: [ + { ts: "1000000000.000003", user: "U123", text: query }, + { + ts: "1000000000.000004", + user: "UBOT", + text: "I am Morgan, replying in this same Slack conversation.", + }, + ], + }, + }; +} +it("requires complete normal existing-app human setup plus actual post-activation two-way native delivery", () => + expect(gradeSlackSetup(valid()).passed).toBe(true)); +it("rejects plausible credential-only, wrong-human/agent, missing callback, activation and routing successes", () => { + for (const mutate of [ + (o: ReturnType) => { + o.state.noEndpointsBefore = false; + }, + (o: ReturnType) => { + o.phaseReplies[0]!.body = "Morgan's Slack is ready; setup is complete."; + }, + (o: ReturnType) => { + o.phaseReplies[0]!.body = + "Connect Slack and select the CEO instead of Morgan."; + }, + (o: ReturnType) => { + o.state.agentEndpointCreateStatus = 201; + }, + (o: ReturnType) => { + o.state.createdByHumanUi = false; + }, + (o: ReturnType) => { + o.slackSetup.endpoint.companyId = "other"; + }, + (o: ReturnType) => { + o.slackSetup.endpoint.assignedAgentId = "ceo"; + }, + (o: ReturnType) => { + o.slackSetup.endpoint.status = "verifying"; + }, + (o: ReturnType) => { + o.slackSetup.endpoint.setup.webhookVerifiedAt = ""; + }, + (o: ReturnType) => { + o.slackSetup.credentialUi.storageClean = false; + }, + (o: ReturnType) => { + o.slackSetup.identityUi.activationUiVisible = false; + }, + (o: ReturnType) => { + o.slackSetup.identityUi.endpointActive = false; + }, + (o: ReturnType) => { + o.slackSetup.signedConnect = false; + }, + (o: ReturnType) => { + o.slackSetup.lastConnectAt = "2031-02-14T10:03:00Z"; + }, + (o: ReturnType) => { + o.slackSetup.humanCommand.verifiedHumanId = "other"; + }, + (o: ReturnType) => { + o.slackSetup.principalUserId = "other"; + }, + (o: ReturnType) => { + o.slackSetup.sourceBound = false; + }, + (o: ReturnType) => { + o.slackSetup.sourceReply.createdByRunId = "previous"; + }, + (o: ReturnType) => { + o.slackSetup.messages = o.slackSetup.messages.slice(0, 1); + }, + (o: ReturnType) => { + o.runs[1]!.agentId = "ceo"; + }, + (o: ReturnType) => { + o.runs[2]!.contextSnapshot.issueId = "unrelated-final"; + }, + (o: ReturnType) => { + o.endpoints.push({ id: "duplicate" }); + }, + (o: ReturnType) => { + o.phaseReplies[1]!.body = + "Slack is not connected yet, and Morgan cannot reply."; + }, + ]) { + const o = valid(); + mutate(o); + expect(gradeSlackSetup(o).passed).toBe(false); + } + expect(gradeSlackSetup(undefined).passed).toBe(false); + for (const key of [ + "endpoint", + "credentialUi", + "identityUi", + "humanCommand", + "sourceReply", + "messages", + ]) { + const o = valid(); + delete (o.slackSetup as Record)[key]; + expect(gradeSlackSetup(o).passed).toBe(false); + } +}); +it("accepts truthful natural prerequisites and rejects premature or wrong represented-agent guidance", () => { + expect( + slackSetupGuidance( + "Slack isn't connected yet. You must use supported setup to connect Morgan and confirm your human identity.", + "Morgan", + true, + ), + ).toBe(true); + expect( + slackSetupGuidance( + "You can set up Slack for Morgan through the supported human authorization page.", + "Morgan", + true, + ), + ).toBe(true); + expect( + slackSetupGuidance( + "Slack is ready. You can configure Morgan later.", + "Morgan", + true, + ), + ).toBe(false); + expect( + slackSetupGuidance( + "Select the CEO to connect Slack; Morgan will use it later.", + "Morgan", + true, + ), + ).toBe(false); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-setup.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-setup.ts new file mode 100644 index 00000000000..f9d0714e65c --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-setup.ts @@ -0,0 +1,711 @@ +import { expect } from "@playwright/test"; +import type { + ChatCatalogContext, + ChatSlackIngressReceipt, +} from "../chat-catalog-contract.js"; +import type { + ChatEndpoint, + ChatConversation, + ExternalChannelBindingSummary, +} from "../../../packages/shared/src/types/chat-channels.js"; +import type { IssueComment } from "../../../packages/shared/src/types/issue.js"; +import { pollUntil } from "../api.js"; +import { sendChatMessage } from "../chat-flow.js"; +import { slackIngressReceiptMatches } from "../chat-slack-ingress.js"; +import { + definition, + checks, + externalObservation, + observeExternal, + root, + verifyRenderedReply, + type ExternalObservation, +} from "./external-support.js"; +import { + openStandingChat, + identityWorker, + waitForReply, + probeAgentPermission, +} from "./identity-support.js"; +import { selectedExternalBinding } from "./external-fixtures.js"; +import { + slackProvider, + assertSlackFixture, + slackCompanionFields, +} from "./external-slack-provider.js"; +import { + prepareSlackFixture, + type SlackMessage, +} from "./external-slack-fixture.js"; +import type { Entity } from "./internal-support.js"; +export const slackSetupFields = [ + ...slackCompanionFields, + "slackBrowserSessionEnv", +] as const; +type Fixture = Awaited>; +interface Local { + fixture?: Fixture; + createdEndpointId?: string; + represented: Entity; + source?: ChatSlackIngressReceipt; + sourceRunId?: string; + sourceReply?: IssueComment; + messages?: SlackMessage[]; +} +const fixtures = new WeakMap(); +function owned(ctx: ChatCatalogContext) { + const local = fixtures.get(ctx); + if (!local) throw new Error("Slack setup context missing"); + return local; +} +interface Observation extends ExternalObservation { + slackSetup: { + endpoint?: ChatEndpoint; + source?: ChatSlackIngressReceipt; + sourceBound: boolean; + sourceRunId?: string; + sourceReply?: IssueComment; + messages: SlackMessage[]; + credentialUi?: Fixture["credentialUiReceipt"]; + identityUi?: Fixture["identityUiReceipt"]; + humanCommand?: Fixture["humanCommandReceipt"]; + signedConnect: boolean; + lastConnectAt?: string | null; + principalUserId?: string | null; + principalStatus?: string; + }; +} +function affirmativeReady(reply: string) { + return reply + .split(/[.!?;\n]+|\bbut\b/i) + .some( + (clause) => + /ready|working|connected|configured|(?:is|are|now|already|has been|have been)\s+set up|(?:setup|connection)\s+(?:is\s+)?complete|can (?:now )?(?:message|talk|chat)/i.test( + clause, + ) && + !/^\s*(?:please\s+)?(?:connect|select|click|continue|set up|get)\b/i.test( + clause, + ) && + !/\b(?:not|isn['’]t|cannot|can't|never|before|once|after|until|pending|will|would|need|requires?|must)\b/i.test( + clause, + ), + ); +} +export function slackSetupGuidance( + reply: string, + represented: string, + forMorgan: boolean, +) { + return ( + /slack/i.test(reply) && + /connect|setup|set up|authorize|authorization|human|sign.in/i.test(reply) && + !affirmativeReady(reply) && + (!forMorgan || /Morgan/i.test(reply)) && + !( + forMorgan && /connect (?:your|the) CEO|select (?:the )?CEO/i.test(reply) + ) && + (!/select|represent/i.test(reply) || + new RegExp(represented.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").test( + reply, + ) || + !forMorgan) + ); +} +export function gradeSlackSetup(value: unknown) { + const c = checks(), + o = externalObservation(value) as Observation | undefined, + t = o?.slackSetup; + c.add( + "actual-human-Slack-setup-evidence", + !!o && !!t && Array.isArray(t.messages), + "Actual saved endpoint, private credential/identity/wizard receipts, genuine signed connect and two-way native source are required.", + ); + if (!o || !t || !Array.isArray(t.messages)) return c.result(); + const first = o.phaseReplies.find( + (row) => row.runId === o.state.initialRunId, + ), + last = o.phaseReplies + .filter((row) => row.runId !== o.state.initialRunId) + .at(-1), + e = t.endpoint, + s = t.source, + represented = o.state.represented as Entity | undefined, + run = o.runs.find((row) => row.id === t.sourceRunId); + c.add( + "unconfigured-context-and-correct-human-handoff", + o.state.noEndpointsBefore === true && + o.state.providerEnabledBefore === true && + !!represented && + !!first && + slackSetupGuidance( + first.body, + String(represented.name), + o.scenarioId === "connection-setup.channel-prerequisites", + ) && + o.state.agentEndpointCreateStatus === 403 && + o.state.createdByHumanUi === true, + "Slack starts available without an endpoint; chat directs the correct represented agent and actual human-created existing-app setup instead of claiming premature readiness.", + ); + c.add( + "real-private-credential-form-and-normal-wizard-activation", + !!e && + e.companyId === o.companyId && + e.provider === "slack" && + e.assignedAgentId === represented?.id && + e.providerAccountId === o.state.workspaceId && + e.status === "active" && + e.setup.step === "complete" && + !!e.setup.webhookVerifiedAt && + e.allowUnlinkedPeople === false && + t.credentialUi?.endpointId === e.id && + t.credentialUi.slackCredentialUiVisible === true && + t.credentialUi.humanSubmittedSecurely === true && + t.credentialUi.storageClean === true && + t.identityUi?.endpointId === e.id && + t.identityUi.identityConfirmed === true && + t.identityUi.identityUiVisible === true && + t.identityUi.activationUiVisible === true && + t.identityUi.endpointActive === true, + "The actual protected existing-app form saves credentials, real callback verification completes and the human uses normal identity/Continue/Done controls; activation is independently saved rather than inferred from credential storage.", + ); + c.add( + "genuine-Slack-human-command-and-same-person-link", + t.signedConnect === true && + t.humanCommand?.commandSubmitted === true && + t.humanCommand.verifiedWorkspaceId === o.state.workspaceId && + t.humanCommand.verifiedHumanId === o.state.senderUserId && + !!t.lastConnectAt && + !!e?.activatedAt && + Date.parse(t.lastConnectAt!) <= Date.parse(e.activatedAt!) && + t.principalStatus === "linked" && + t.principalUserId === o.state.currentHumanId && + t.identityUi?.paperclipUserId === o.state.currentHumanId && + t.identityUi?.processIsolation === true && + t.identityUi?.testRunnerAttached === false, + "Only a real named Slack browser human, independently copied member ID, genuinely provider-signed connect command and persisted processed principal receipt authorize the same actual signed-in Paperclip person.", + ); + c.add( + "two-way-message-after-actual-activation", + !!s && + t.sourceBound && + !!run && + run.companyId === o.companyId && + run.agentId === represented?.id && + run.contextSnapshot?.issueId === s.issueId && + run.runtimeMode === "native" && + run.status === "succeeded" && + t.sourceReply?.createdByRunId === run.id && + t.sourceReply.authorAgentId === represented?.id && + !!represented?.name && + t.sourceReply.body.includes(represented.name) && + t.messages.some( + (message) => + message.ts === s.provider.canonicalMessageId && + message.user === o.state.senderUserId && + message.text === s.query, + ) && + t.messages.some( + (message) => + message.user === o.state.botUserId && + message.ts > s.provider.canonicalMessageId && + (message.text ?? "").includes(represented.name!), + ), + "An actual subsequent synthetic human message persists under the right company/sender/thread; the genuine represented-agent native reply is independently read back from that same provider thread.", + ); + c.add( + "separate-truthful-ready-confirmation-and-no-extra-work", + !!last && + affirmativeReady(last.body) && + /slack/i.test(last.body) && + (o.scenarioId !== "connection-setup.channel-prerequisites" || + /Morgan/i.test(last.body)) && + o.runs.length === 3 && + o.runs.every( + (row) => + row.companyId === o.companyId && + row.runtimeMode === "native" && + row.status === "succeeded" && + (row.id === t.sourceRunId || + (row.agentId === o.leadId && + row.contextSnapshot?.issueId === o.state.canonicalIssueId && + [o.state.initialRunId, last.runId].includes(row.id))), + ) && + o.humanQueries.length === 2 && + o.endpoints.length === 1, + "Only the two canonical Agent Chat phases and separately attributed two-way Slack probe run exist; the final ready claim follows actual delivery and does not manufacture an additional endpoint.", + ); + return c.result(); +} +function setupCase( + id: "chat-channels.connect-slack" | "connection-setup.channel-prerequisites", +) { + return definition( + id, + async (ctx) => { + const prior = await ctx.input.api.get<{ enableChatConnectors?: boolean }>( + "/api/instance/settings/experimental", + ); + await ctx.input.api.patch("/api/instance/settings/experimental", { + enableChatConnectors: true, + }); + const represented = + id === "connection-setup.channel-prerequisites" + ? await identityWorker(ctx, "Morgan") + : await ctx.input.api.get( + `/api/agents/${ctx.input.fixtures.agent.id}`, + ); + const endpoints = await ctx.input.api.get( + `${root(ctx)}/chat-endpoints`, + ); + if (endpoints.length) + throw new Error( + "Slack setup requires an initially unconfigured disposable company", + ); + fixtures.set(ctx, { represented }); + const chat = await openStandingChat(ctx); + await ctx.input.api.patch(`/api/issues/${chat.id}`, { + description: `Slack chat is enabled, but this company has no chat endpoint or linked sender. The requested represented agent is ${represented.name} (${represented.id}); that agent already has an eligible runtime. The signed-in human must connect their authorized disposable existing Slack app through supported chat setup.`, + }); + return { + represented, + noEndpointsBefore: true, + providerEnabledBefore: true, + canonicalIssueId: chat.id, + priorChatConnectors: prior.enableChatConnectors === true, + }; + }, + gradeSlackSetup, + { + authentication: "human-session", + expectedRunCount: 3, + minimumRunCount: 3, + expectedHumanMessageCount: 2, + turnTimeoutMs: 120000, + attemptTimeoutMs: 900000, + externalInput: { provider: "slack", fields: slackSetupFields }, + sourceFiles: [ + "chat-catalog-domains/external-slack-setup.ts", + "chat-catalog-domains/external-slack-fixture.ts", + "chat-catalog-domains/external-slack-provider.ts", + "chat-catalog-domains/external-slack-relay.ts", + "chat-catalog-domains/external-slack-human.ts", + "chat-catalog-domains/external-slack-human-worker.ts", + "chat-catalog-domains/external-slack-human-contract.ts", + "chat-catalog-domains/external-private-browser.ts", + "chat-catalog-domains/external-private-browser-worker.ts", + "chat-catalog-domains/external-slack-setup-private-contract.ts", + "chat-catalog-domains/external-slack-identity-contract.ts", + "chat-catalog-domains/external-fixtures.ts", + "chat-catalog-domains/external-support.ts", + "chat-catalog-domains/internal-support.ts", + "chat-catalog-domains/identity-support.ts", + "chat-slack-ingress.ts", + "../../ui/src/pages/apps/chat/ChatEndpointSetup.tsx", + "../../ui/src/pages/apps/chat/SlackIdentityStep.tsx", + "../../ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx", + "../../server/src/services/chat-channels/setup-probes.ts", + "../../server/src/services/chat-channels/slack/commands.ts", + "../../server/src/services/chat-channels/slack/identity-links.ts", + "../../server/src/services/chat-channels/identity-links.ts", + "../../server/src/services/chat-channels.ts", + `chat-scenarios/${id.split(".")[0]}.yaml`, + ], + async verifySetup(ctx, state) { + const endpoints = await ctx.input.api.get( + `${root(ctx)}/chat-endpoints`, + ), + enabled = await ctx.input.api.get<{ enableChatConnectors?: boolean }>( + "/api/instance/settings/experimental", + ); + const ready = + endpoints.length === 0 && + enabled.enableChatConnectors === true && + (await ctx.allRuns()).length === 0; + return { + passed: ready, + checks: [ + { + id: "real-unconfigured-Slack-context", + passed: ready, + detail: + "Actual available provider and company endpoint inventory have no configured route or native work.", + }, + ], + }; + }, + async act(ctx, state) { + await waitForReply(ctx); + const local = owned(ctx), + initial = await observeExternal(ctx, state), + first = initial.phaseReplies.at(-1); + if ( + !first || + !slackSetupGuidance( + first.body, + String(local.represented.name), + id === "connection-setup.channel-prerequisites", + ) + ) + throw new Error( + "Wrong represented-agent guidance or premature ready claim cannot be rescued by correct human setup", + ); + state.initialRunId = first.runId; + if ( + ( + await ctx.input.api.get( + `${root(ctx)}/chat-endpoints`, + ) + ).length + ) + throw new Error( + "An endpoint appeared before the required human creation", + ); + const href = initial.links.find((link) => { + try { + const u = new URL(link, initial.origin); + return ( + u.origin === initial.origin && + u.pathname === + `/${ctx.input.fixtures.company.issuePrefix}/apps/chat/connect` && + u.searchParams.get("provider") === "slack" && + (!u.searchParams.has("agentId") || + u.searchParams.get("agentId") === local.represented.id) && + !u.searchParams.has("resume") + ); + } catch { + return false; + } + }); + if (!href) + throw new Error( + "Slack human setup must be directed to its actual supported chat setup and requested agent", + ); + state.agentEndpointCreateStatus = await probeAgentPermission( + ctx, + `${root(ctx)}/chat-endpoints`, + "POST", + { provider: "slack", assignedAgentId: local.represented.id }, + ); + const binding = selectedExternalBinding(ctx); + assertSlackFixture(binding); + const provider = slackProvider(ctx, binding), + manifest = ( + await provider.call<{ + ok: true; + manifest: { + features?: { slash_commands?: Array<{ command: string }> }; + }; + }>("configuration", "apps.manifest.export", { + app_id: binding.prerequisites.appId, + }) + ).manifest; + const commands = + manifest.features?.slash_commands?.map((row) => row.command) ?? []; + if (commands.length !== 1 || !/^\/[a-z0-9_-]{1,31}$/.test(commands[0]!)) + throw new Error( + "Setup blocker: the designated existing app must expose one supported saved slash command", + ); + const page = await ctx.input.page.context().newPage(); + try { + await page.goto(href, { waitUntil: "domcontentloaded" }); + const purpose = page.getByRole("button", { + name: /^Chat with an agent/, + }); + if (await purpose.isVisible()) await purpose.click(); + await page + .getByRole("heading", { + name: "Which agent do you want to chat with?", + exact: true, + }) + .waitFor(); + const agentButton = page.getByRole("button", { + name: String(local.represented.name), + exact: true, + }); + if (!(await agentButton.isVisible())) { + await page + .getByRole("button", { + name: "Choose an active agent", + exact: true, + }) + .click(); + await page + .getByRole("button", { + name: `Select ${local.represented.name}`, + exact: true, + }) + .click(); + } + await page + .locator("summary") + .filter({ hasText: /^Advanced$/ }) + .click(); + await page + .getByLabel("Slash command", { exact: true }) + .fill(commands[0]!); + const created = page.waitForResponse( + (response) => + new URL(response.url()).pathname === + `${root(ctx)}/chat-endpoints` && + response.request().method() === "POST" && + response.status() === 201, + ); + const own = page.getByRole("button", { + name: "Use your own app", + exact: true, + }); + if (await own.isVisible()) await own.click(); + else + await page + .getByRole("button", { name: "Continue", exact: true }) + .click(); + const response = await created, + endpoint = (await response.json()) as ChatEndpoint; + local.createdEndpointId = endpoint.id; + if ( + endpoint.companyId !== ctx.input.fixtures.company.id || + endpoint.provider !== "slack" || + endpoint.assignedAgentId !== local.represented.id + ) + throw new Error( + "Actual UI created another represented-agent/company endpoint", + ); + const existing = page.getByRole("button", { + name: "Use an existing app", + exact: true, + }); + await page + .locator("details") + .filter({ has: existing }) + .locator("summary") + .click(); + const saved = page.waitForResponse( + (response) => + new URL(response.url()).pathname === + `/api/chat-endpoints/${endpoint.id}` && + response.request().method() === "PATCH" && + response.ok(), + ); + await existing.click(); + if ( + (await (await saved).json()).setup?.slackSetupMethod !== "existing" + ) + throw new Error("Actual existing-app choice did not persist"); + state.createdByHumanUi = true; + } finally { + await page.close(); + } + local.fixture = await prepareSlackFixture(ctx, { + normalWizard: { + endpointId: local.createdEndpointId!, + agentId: local.represented.id, + }, + }); + const f = local.fixture; + state.workspaceId = f.binding.prerequisites.workspaceId; + state.senderUserId = f.binding.prerequisites.senderUserId; + state.botUserId = f.binding.prerequisites.botUserId; + state.currentHumanId = ( + await ctx.input.api.get<{ user: { id: string } }>( + "/api/auth/get-session", + ) + ).user.id; + const query = + "Please say your exact Paperclip agent name and confirm you can reply in this same Slack conversation. Do not start a separate task."; + f.relay.expectCanonical(query, f.threadId); + const start = f.provider.receipts.length; + const sent = await f.provider.call<{ + ok: true; + channel: string; + ts: string; + message: { text: string; user: string; thread_ts: string }; + }>("sender", "chat.postMessage", { + channel: f.binding.prerequisites.channelId, + thread_ts: f.threadId, + text: query, + unfurl_links: false, + unfurl_media: false, + }); + if ( + sent.channel !== f.binding.prerequisites.channelId || + sent.message.user !== f.binding.prerequisites.senderUserId || + sent.message.text !== query || + sent.message.thread_ts !== f.threadId + ) + throw new Error( + "Actual two-way Slack probe changed sender/thread/text", + ); + await pollUntil({ + label: "actual signed Slack post-activation message", + deadlineAt: Date.now() + 30000, + load: async () => f.relay.receipt(), + accept: (row) => row.canonicalDeliveryCaptured, + }); + local.source = await f.resolveIngress( + query, + sent.ts, + f.provider.receipts.length - start, + ); + const runs = await pollUntil({ + label: "genuine represented-agent Slack two-way native", + deadlineAt: Date.now() + 150000, + intervalMs: 1000, + load: () => ctx.allRuns(), + accept: (rows) => + rows.length === 2 && + rows.some( + (row) => + row.agentId === local.represented.id && + row.contextSnapshot?.issueId === local.source!.issueId && + row.status === "succeeded" && + row.runtimeMode === "native", + ), + reject: (rows) => + rows.length > 2 || + rows.some((row) => + ["failed", "cancelled", "timed_out"].includes(row.status), + ) + ? "Unexpected or failed real Slack setup probe native" + : undefined, + }); + local.sourceRunId = runs.find( + (row) => row.contextSnapshot?.issueId === local.source!.issueId, + )!.id; + const comments = await ctx.input.api.get( + `/api/issues/${local.source.issueId}/comments?order=asc`, + ); + local.sourceReply = comments + .filter( + (row) => + row.createdByRunId === local.sourceRunId && + row.authorAgentId === local.represented.id, + ) + .at(-1); + local.messages = await pollUntil({ + label: "actual represented-agent reply back to original Slack thread", + deadlineAt: Date.now() + 60000, + intervalMs: 2000, + load: () => f.readThread(f.threadId), + accept: (rows) => + rows.some( + (row) => + row.user === f.binding.prerequisites.botUserId && + row.ts > sent.ts && + (row.text ?? "").includes(String(local.represented.name)), + ), + }); + await ctx.input.evidence("chat-catalog-slack-actual-human-setup.json", { + credentialUi: f.credentialUiReceipt, + identityUi: f.identityUiReceipt, + humanCommand: f.humanCommandReceipt, + signedConnect: f.relay.receipt().humanConnectCaptured, + source: local.source, + sourceRunId: local.sourceRunId, + sourceReply: local.sourceReply, + messages: local.messages, + }); + await sendChatMessage(ctx.input.page, ctx.scenario.query); + }, + async observe(ctx, state) { + const o = await observeExternal(ctx, state), + local = owned(ctx), + f = local.fixture; + let sourceBound = false; + let endpoint: ChatEndpoint | undefined, + principal: Awaited> | undefined; + if (f) { + endpoint = await ctx.input.api.get( + `/api/chat-endpoints/${f.endpoint.id}`, + ); + principal = await f.readPrincipal(); + if (local.source) { + const [conversations, binding, comments] = await Promise.all([ + ctx.input.api.get( + `/api/chat-endpoints/${f.endpoint.id}/conversations`, + ), + ctx.input.api.get( + `/api/issues/${local.source.issueId}/chat-binding`, + ), + ctx.input.api.get( + `/api/issues/${local.source.issueId}/comments?order=asc`, + ), + ]); + sourceBound = slackIngressReceiptMatches({ + receipt: local.source, + query: local.source.query, + companyId: ctx.input.fixtures.company.id, + agentId: local.represented.id, + endpoint, + conversation: conversations.find( + (row) => row.id === local.source!.conversationId, + ), + binding, + comment: comments.find( + (row) => row.id === local.source!.humanCommentId, + ), + links: [principal], + linksEndpointId: endpoint.id, + senderIdentity: f.senderIdentity, + }); + } + } + return { + ...o, + slackSetup: { + endpoint, + source: local.source, + sourceBound, + sourceRunId: local.sourceRunId, + sourceReply: local.sourceReply, + messages: local.messages ?? [], + credentialUi: f?.credentialUiReceipt, + identityUi: f?.identityUiReceipt, + humanCommand: f?.humanCommandReceipt, + signedConnect: f?.relay.receipt().humanConnectCaptured === true, + lastConnectAt: principal?.lastConnectAt, + principalUserId: principal?.paperclipUserId, + principalStatus: principal?.status, + }, + } satisfies Observation; + }, + async verifyResult(ctx, _state, value) { + const o = value as Observation; + await verifyRenderedReply(ctx, value); + if (!o.slackSetup.endpoint) + throw new Error("Actual activated Slack endpoint missing"); + const page = await ctx.input.page.context().newPage(); + try { + await page.goto( + `${o.origin}/${ctx.input.fixtures.company.issuePrefix}/apps/chat/${o.slackSetup.endpoint.id}/settings`, + { waitUntil: "domcontentloaded" }, + ); + await expect(page.getByRole("main")).toContainText( + String((o.state.represented as Entity).name), + ); + } finally { + await page.close(); + } + }, + async cleanup(ctx, state) { + const local = fixtures.get(ctx); + try { + if (local?.fixture) await local.fixture.cleanup(); + else if (local?.createdEndpointId) + await ctx.input.api.post( + `/api/chat-endpoints/${local.createdEndpointId}/setup`, + { action: "remove" }, + ); + } finally { + fixtures.delete(ctx); + await ctx.input.api.patch("/api/instance/settings/experimental", { + enableChatConnectors: state.priorChatConnectors === true, + }); + } + }, + }, + ); +} +export const slackSetupExternalCases = [ + setupCase("chat-channels.connect-slack"), + setupCase("connection-setup.channel-prerequisites"), +];