mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Author normal Slack setup with private human slash command and real wizard activation
This commit is contained in:
1 parent
eeccfd9bde
commit
b21eeaa239
17 files changed
+2289
-87
No files matched your search
@@ -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.
|
||||
@@ -26,8 +26,8 @@ export const externalRequirements: Record<string, { provider: ExternalProvider;
|
||||
"connection-setup.notion-expired": { provider: "notion", fields: ["workspaceId", "recentPageId", "expiredAuthorizationEnv", "oauthBrowserSession"] },
|
||||
"connection-setup.return-from-login": { provider: "notion", fields: ["workspaceId", "recentPageId", "oauthBrowserSession", "registeredCallbackOrigin"] },
|
||||
"connection-setup.save-and-resume": { provider: "notion", fields: ["workspaceId", "recentPageId", "oauthBrowserSession"] },
|
||||
"connection-setup.channel-prerequisites": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "registeredWebhookOrigin"] },
|
||||
"chat-channels.connect-slack": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "registeredWebhookOrigin"] },
|
||||
"connection-setup.channel-prerequisites": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "botUserId", "appId", "registeredWebhookOrigin", "webhookRelayPort", "botTokenEnv", "senderTokenEnv", "signingSecretEnv", "configurationTokenEnv", "slackBrowserSessionEnv"] },
|
||||
"chat-channels.connect-slack": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "botUserId", "appId", "registeredWebhookOrigin", "webhookRelayPort", "botTokenEnv", "senderTokenEnv", "signingSecretEnv", "configurationTokenEnv", "slackBrowserSessionEnv"] },
|
||||
"chat-channels.link-sender": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "botUserId", "appId", "registeredWebhookOrigin", "webhookRelayPort", "botTokenEnv", "senderTokenEnv", "signingSecretEnv", "configurationTokenEnv"] },
|
||||
"chat-channels.reply-in-thread": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "botUserId", "appId", "registeredWebhookOrigin", "webhookRelayPort", "botTokenEnv", "senderTokenEnv", "signingSecretEnv", "configurationTokenEnv"] },
|
||||
"chat-channels.channel-file": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "botUserId", "appId", "registeredWebhookOrigin", "webhookRelayPort", "botTokenEnv", "senderTokenEnv", "signingSecretEnv", "configurationTokenEnv"] },
|
||||
|
||||
@@ -2,15 +2,16 @@ import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, test, type Cookie } from "@playwright/test";
|
||||
import { validGrokRoutinePrivateInput, matchesConfiguredGrokRoutine, type GrokRoutinePrivateInput } from "./external-grok-private-contract.js";
|
||||
import { validSlackIdentityInput, matchingSlackIdentityPreview, type SlackIdentityPrivateInput } from "./external-slack-identity-contract.js";
|
||||
interface Request { 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[]; executablePath?: string; channel?: string }
|
||||
import { validSlackIdentityInput, validSlackWizardIdentityInput, matchingSlackIdentityPreview, type SlackIdentityPrivateInput } from "./external-slack-identity-contract.js";
|
||||
import { validSlackSetupPrivate, type SlackSetupPrivateInput } from "./external-slack-setup-private-contract.js";
|
||||
interface Request { 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[]; executablePath?: string; channel?: string }
|
||||
/** Separate Node process: no Playwright Test worker, API-step listener or artifact
|
||||
* recorder exists. This module never enables tracing, video or screenshots. */
|
||||
async function main() {
|
||||
let attached = false; try { test.info(); attached = true; } catch { /* expected outside Test */ }
|
||||
if (attached) throw new Error("Private browser worker cannot run inside Playwright Test");
|
||||
const input = JSON.parse(await readFile(process.argv[2]!, "utf8")) as 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"].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");
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string,unknown>,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<string,any>;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<string, unknown>,
|
||||
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<string, any>;
|
||||
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<ReturnType<typeof startSlackRelay>>|undefined,originalManifest:Record<string,unknown>|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<ChatEndpoint>(`${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<ChatEndpoint>(`/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<string,unknown>}>("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<ChatEndpoint>(`/api/chat-endpoints/${endpoint!.id}`),accept:row=>!!row.setup.webhookVerifiedAt});
|
||||
endpoint=await ctx.input.api.post<ChatEndpoint>(`/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<ChatEndpointResource[]>(`/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<Principal[]>(`/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<Principal[]>(`/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<ChatEndpoint>(`/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<Principal[]>(`/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<ReturnType<typeof startSlackRelay>> | undefined,
|
||||
originalManifest: Record<string, unknown> | 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<ChatEndpoint>(
|
||||
`/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<ChatEndpoint>(
|
||||
`${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<ChatEndpoint>(
|
||||
`/api/chat-endpoints/${endpoint.id}`,
|
||||
);
|
||||
} else
|
||||
endpoint = await ctx.input.api.postSensitive<ChatEndpoint>(
|
||||
`/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<string, unknown>;
|
||||
}>("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<ChatEndpoint>(`/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<ChatEndpoint>(
|
||||
`/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<ChatEndpointResource[]>(
|
||||
`/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<Principal[]>(
|
||||
`/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<Principal[]>(
|
||||
`/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<Principal[]>(
|
||||
`/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<ChatEndpoint>(
|
||||
`/api/chat-endpoints/${endpoint.id}`,
|
||||
)
|
||||
: await ctx.input.api.post<ChatEndpoint>(
|
||||
`/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<Principal[]>(
|
||||
`/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<Principal[]>(
|
||||
`/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<Principal[]>(
|
||||
`/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<ChatSlackIngressReceipt> {
|
||||
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<ChatConversation[]>(
|
||||
`/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<Principal[]>(`/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<Principal[]>(`/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<ChatSlackIngressReceipt>{
|
||||
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<ChatConversation[]>(`/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;}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<SlackHumanConnectInput, "workspaceId" | "senderUserId">,
|
||||
) {
|
||||
const r = value as Partial<SlackHumanConnectReceipt> | 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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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<SlackHumanConnectInput, "session"> & { 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<string,string>,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<void>((resolve,reject)=>server.close(error=>error?reject(new Error("Slack relay cleanup failed")):resolve()));},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof valid>) => {
|
||||
o.state.noEndpointsBefore = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.phaseReplies[0]!.body = "Morgan's Slack is ready; setup is complete.";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.phaseReplies[0]!.body =
|
||||
"Connect Slack and select the CEO instead of Morgan.";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.state.agentEndpointCreateStatus = 201;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.state.createdByHumanUi = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.endpoint.companyId = "other";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.endpoint.assignedAgentId = "ceo";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.endpoint.status = "verifying";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.endpoint.setup.webhookVerifiedAt = "";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.credentialUi.storageClean = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.identityUi.activationUiVisible = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.identityUi.endpointActive = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.signedConnect = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.lastConnectAt = "2031-02-14T10:03:00Z";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.humanCommand.verifiedHumanId = "other";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.principalUserId = "other";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.sourceBound = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.sourceReply.createdByRunId = "previous";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.slackSetup.messages = o.slackSetup.messages.slice(0, 1);
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.runs[1]!.agentId = "ceo";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.runs[2]!.contextSnapshot.issueId = "unrelated-final";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.endpoints.push({ id: "duplicate" });
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
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<string, unknown>)[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);
|
||||
});
|
||||
@@ -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<ReturnType<typeof prepareSlackFixture>>;
|
||||
interface Local {
|
||||
fixture?: Fixture;
|
||||
createdEndpointId?: string;
|
||||
represented: Entity;
|
||||
source?: ChatSlackIngressReceipt;
|
||||
sourceRunId?: string;
|
||||
sourceReply?: IssueComment;
|
||||
messages?: SlackMessage[];
|
||||
}
|
||||
const fixtures = new WeakMap<ChatCatalogContext, Local>();
|
||||
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<Entity>(
|
||||
`/api/agents/${ctx.input.fixtures.agent.id}`,
|
||||
);
|
||||
const endpoints = await ctx.input.api.get<ChatEndpoint[]>(
|
||||
`${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<ChatEndpoint[]>(
|
||||
`${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<ChatEndpoint[]>(
|
||||
`${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<IssueComment[]>(
|
||||
`/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<ReturnType<Fixture["readPrincipal"]>> | undefined;
|
||||
if (f) {
|
||||
endpoint = await ctx.input.api.get<ChatEndpoint>(
|
||||
`/api/chat-endpoints/${f.endpoint.id}`,
|
||||
);
|
||||
principal = await f.readPrincipal();
|
||||
if (local.source) {
|
||||
const [conversations, binding, comments] = await Promise.all([
|
||||
ctx.input.api.get<ChatConversation[]>(
|
||||
`/api/chat-endpoints/${f.endpoint.id}/conversations`,
|
||||
),
|
||||
ctx.input.api.get<ExternalChannelBindingSummary>(
|
||||
`/api/issues/${local.source.issueId}/chat-binding`,
|
||||
),
|
||||
ctx.input.api.get<IssueComment[]>(
|
||||
`/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"),
|
||||
];
|
||||
Reference in new issue
Block a user