mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Author real Slack authorization expiry and normal human reconnect proof
This commit is contained in:
1 parent
81d12acc6e
commit
62e31c5632
14 files changed
+1503
-21
No files matched your search
@@ -236,3 +236,27 @@ 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.
|
||||
|
||||
`chat-channels.repair-endpoint` selects the same Slack fields plus
|
||||
`botTokenExpiresAt` and `repairBotTokenEnv`. The original bot token must still
|
||||
successfully identify the designated bot during historical fixture qualification
|
||||
and naturally expire within the bounded two-to-eight-minute window. The separate
|
||||
repair token must independently identify that same bot/app/workspace and remain
|
||||
valid. No token is revoked, rotated, exchanged or minted by this fixture. Initial
|
||||
historical qualification uses the actual public board configure route, not the
|
||||
normal UI under evaluation. The SDK generation is invalidated by an actual
|
||||
pause/resume while the old credential still works. Its next genuine signed
|
||||
intake must fail after the official provider returns `token_expired`, without a
|
||||
setup native run; any other starting condition blocks qualification.
|
||||
|
||||
The tested human repair uses the actual protected Reconnect form, the selected
|
||||
human's Slack App Settings Request URL retry/re-entry, fresh provider-signed
|
||||
verification, normal existing-person wizard continuation and Done. A later new
|
||||
same-thread native reply is still mandatory. Expiry timing is credential-fixture
|
||||
metadata, not an expected model answer. Missing qualified tokens/session/app
|
||||
management authority are prerequisites, never positive coverage. The case is
|
||||
bounded at 25 minutes and performs no external operations during offline
|
||||
calibration. A visible real form rejection is retained as a nonpassing product
|
||||
observation; the harness does not pre-reject an actual rotating-token format or
|
||||
bypass that form. Slack's documented rotating access-token format is described at
|
||||
https://docs.slack.dev/authentication/using-token-rotation/.
|
||||
@@ -31,7 +31,7 @@ export const externalRequirements: Record<string, { provider: ExternalProvider;
|
||||
"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"] },
|
||||
"chat-channels.repair-endpoint": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "expiredAuthorizationEnv", "registeredWebhookOrigin"] },
|
||||
"chat-channels.repair-endpoint": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "botUserId", "appId", "registeredWebhookOrigin", "webhookRelayPort", "botTokenEnv", "senderTokenEnv", "signingSecretEnv", "configurationTokenEnv", "slackBrowserSessionEnv", "botTokenExpiresAt", "repairBotTokenEnv"] },
|
||||
"chat-channels.outbound-email": { provider: "agentmail", fields: ["inboxId", "recipient", "recipientAuthorization", "apiTokenEnv"] },
|
||||
"chat-channels.reply-to-email": { provider: "agentmail", fields: ["inboxId", "recipient", "recipientAuthorization", "apiTokenEnv", "reviewerAddress", "reviewerAuthorization", "senderCredentialEnv"] },
|
||||
"secrets.vault-reference": { provider: "vault", fields: ["provider", "region", "deploymentId", "kmsKeyId", "secretReference", "accessKeyIdEnv", "secretAccessKeyEnv", "sessionTokenEnv", "expectedValueEnv"] },
|
||||
|
||||
@@ -4,14 +4,14 @@ import { chromium, test, type Cookie } from "@playwright/test";
|
||||
import { validGrokRoutinePrivateInput, matchesConfiguredGrokRoutine, type GrokRoutinePrivateInput } from "./external-grok-private-contract.js";
|
||||
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 }
|
||||
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" | "slack-wizard-resume" | "slack-endpoint-reconnect"; 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", "slack-endpoint-configure", "slack-wizard-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", "slack-wizard-resume", "slack-endpoint-reconnect"].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 } : {}) });
|
||||
@@ -24,20 +24,22 @@ 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"){
|
||||
if(input.mode === "slack-wizard-confirm" || input.mode === "slack-wizard-resume"){
|
||||
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");
|
||||
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||(input.mode==="slack-wizard-resume"?(candidate[0]!.status!=="linked"||candidate[0]!.paperclipUserId!==identity.userId):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"});
|
||||
if(input.mode==="slack-wizard-confirm"){
|
||||
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();
|
||||
@@ -45,22 +47,26 @@ async function main() {
|
||||
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};
|
||||
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,identityWasAlreadyLinked:input.mode==="slack-wizard-resume"};
|
||||
}
|
||||
if(input.mode === "slack-endpoint-configure"){
|
||||
if(input.mode === "slack-endpoint-configure" || input.mode === "slack-endpoint-reconnect"){
|
||||
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");
|
||||
if(!validSlackSetupPrivate(input.companyId,slack,input.setupURL,input.mode==="slack-endpoint-reconnect")||!slack||!input.companyId)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");
|
||||
if(before.companyId!==input.companyId||before.provider!=="slack"||before.assignedAgentId!==slack.agentId||before.setup?.slackSetupMethod!=="existing"||(input.mode==="slack-endpoint-reconnect"?before.providerAccountId!==slack.workspaceId:!!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);
|
||||
if(await page.getByText("Your Bot User OAuth Token must start with",{exact:false}).isVisible()){
|
||||
return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,endpointId:slack.endpointId,slackCredentialUiVisible:true,humanSubmittedSecurely:false,credentialUiRejected:true};
|
||||
}
|
||||
|
||||
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");
|
||||
await page.getByRole("button",{name:input.mode==="slack-endpoint-reconnect"?"Reconnect Slack app":"Connect Slack app",exact:true}).click();const response=await saved,submitted=response.request().postDataJSON();
|
||||
if(submitted.action!==(input.mode==="slack-endpoint-reconnect"?"reconnect":"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]);
|
||||
|
||||
@@ -8,8 +8,8 @@ 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";
|
||||
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) {
|
||||
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; identityWasAlreadyLinked?:boolean; credentialUiRejected?: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" | "slack-wizard-resume" | "slack-endpoint-reconnect"; 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");
|
||||
@@ -24,9 +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", "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");
|
||||
|| 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", "identityWasAlreadyLinked", "credentialUiRejected"].includes(key))) throw new Error("Private browser receipt exceeds its closed contract");
|
||||
if ((input.mode === "slack-wizard-confirm" || input.mode === "slack-wizard-resume") && (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" || input.mode === "slack-endpoint-reconnect") && (receipt.endpointId !== input.slackSetup?.endpointId || receipt.slackCredentialUiVisible !== true || (receipt.credentialUiRejected === true ? receipt.humanSubmittedSecurely !== false : 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") {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
import {
|
||||
selectedExternalCredential,
|
||||
type ProviderBinding,
|
||||
} from "./external-fixtures.js";
|
||||
export interface SlackAuthorizationReceipt {
|
||||
ok: boolean;
|
||||
identityMatches: boolean;
|
||||
code: "ok" | "token_expired" | "token_revoked" | "invalid_auth" | "other";
|
||||
}
|
||||
export function slackAuthorizationReceipt(
|
||||
value: unknown,
|
||||
binding: ProviderBinding,
|
||||
): SlackAuthorizationReceipt {
|
||||
const row = value as
|
||||
| { ok?: unknown; error?: unknown; team_id?: unknown; user_id?: unknown }
|
||||
| undefined;
|
||||
if (row?.ok === true)
|
||||
return {
|
||||
ok: true,
|
||||
identityMatches:
|
||||
row.team_id === binding.prerequisites.workspaceId &&
|
||||
row.user_id === binding.prerequisites.botUserId,
|
||||
code: "ok",
|
||||
};
|
||||
const code = ["token_expired", "token_revoked", "invalid_auth"].includes(
|
||||
String(row?.error),
|
||||
)
|
||||
? (row!.error as "token_expired" | "token_revoked" | "invalid_auth")
|
||||
: "other";
|
||||
return { ok: false, identityMatches: false, code };
|
||||
}
|
||||
/** One fixed official auth probe, selected private refs only; unknown account
|
||||
* metadata and response bodies never escape this sensitive Node transport. */
|
||||
export async function probeSlackAuthorization(
|
||||
ctx: ChatCatalogContext,
|
||||
binding: ProviderBinding,
|
||||
field: "botTokenEnv" | "repairBotTokenEnv",
|
||||
): Promise<SlackAuthorizationReceipt> {
|
||||
const token = selectedExternalCredential(ctx, binding, field);
|
||||
try {
|
||||
const response = await fetch("https://slack.com/api/auth.test", {
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(20000),
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "",
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error();
|
||||
}
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.length > 65536) throw new Error();
|
||||
return slackAuthorizationReceipt(
|
||||
JSON.parse(bytes.toString("utf8")),
|
||||
binding,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Setup blocker: selected Slack authorization probe unavailable; private response withheld",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,8 @@ export async function prepareSlackFixture(
|
||||
ctx: ChatCatalogContext,
|
||||
options: {
|
||||
unlinkBeforeCanonical?: boolean;
|
||||
observeWithRepairCredential?: boolean;
|
||||
useExistingAppCommand?: boolean;
|
||||
normalWizard?: { endpointId: string; agentId: string };
|
||||
} = {},
|
||||
) {
|
||||
@@ -107,7 +109,15 @@ export async function prepareSlackFixture(
|
||||
const priorExperiments = await ctx.input.api.get<{
|
||||
enableChatConnectors?: boolean;
|
||||
}>("/api/instance/settings/experimental");
|
||||
const provider = slackProvider(ctx, binding);
|
||||
const provider = slackProvider(
|
||||
ctx,
|
||||
options.observeWithRepairCredential
|
||||
? {
|
||||
...binding,
|
||||
prerequisites: { ...p, botTokenEnv: p.repairBotTokenEnv! },
|
||||
}
|
||||
: binding,
|
||||
);
|
||||
let endpoint: ChatEndpoint | undefined,
|
||||
relay: Awaited<ReturnType<typeof startSlackRelay>> | undefined,
|
||||
originalManifest: Record<string, unknown> | undefined,
|
||||
@@ -182,6 +192,33 @@ export async function prepareSlackFixture(
|
||||
throw new Error(
|
||||
"Setup blocker: actual server PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL must match designated registeredWebhookOrigin before any Slack operation",
|
||||
);
|
||||
if (options.useExistingAppCommand) {
|
||||
const current = await provider.call<{
|
||||
ok: true;
|
||||
manifest: {
|
||||
features?: { slash_commands?: Array<{ command: string }> };
|
||||
};
|
||||
}>("configuration", "apps.manifest.export", { app_id: p.appId });
|
||||
const commands = current.manifest.features?.slash_commands ?? [];
|
||||
if (
|
||||
commands.length !== 1 ||
|
||||
!/^\/[a-z0-9_-]{1,31}$/.test(commands[0]!.command) ||
|
||||
!endpoint.setup.slackApp
|
||||
)
|
||||
throw new Error(
|
||||
"Setup blocker: selected installed Slack app must expose one supported saved command",
|
||||
);
|
||||
endpoint = await ctx.input.api.patch<ChatEndpoint>(
|
||||
`/api/chat-endpoints/${endpoint.id}`,
|
||||
{
|
||||
slackSetupMethod: "existing",
|
||||
slackApp: {
|
||||
...endpoint.setup.slackApp,
|
||||
command: commands[0]!.command,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
relay = await startSlackRelay({
|
||||
port,
|
||||
endpointPublicId: endpoint.publicId,
|
||||
@@ -267,6 +304,15 @@ export async function prepareSlackFixture(
|
||||
credential: selectedExternalCredential(ctx, binding, "botTokenEnv"),
|
||||
cookies: await ctx.input.page.context().cookies(ctx.input.api.baseURL),
|
||||
});
|
||||
if (credentialUiReceipt.credentialUiRejected) {
|
||||
await ctx.input.evidence(
|
||||
"chat-catalog-Slack-credential-UI-rejected.json",
|
||||
credentialUiReceipt,
|
||||
);
|
||||
throw new Error(
|
||||
"Product Slack credential form rejected the actual selected bot-token format; connectivity remains incomplete",
|
||||
);
|
||||
}
|
||||
endpoint = await ctx.input.api.get<ChatEndpoint>(
|
||||
`/api/chat-endpoints/${endpoint.id}`,
|
||||
);
|
||||
|
||||
@@ -78,3 +78,41 @@ it("requires independently copied exact human ID and closed isolated browser rec
|
||||
])
|
||||
expect(validSlackHumanReceipt(wrong, input)).toBe(false);
|
||||
});
|
||||
|
||||
it("permits only exact app Request URL retry and distinct closed action evidence", () => {
|
||||
const retry = {
|
||||
...input,
|
||||
operation: "retry-callback" as const,
|
||||
appId: "A123",
|
||||
callbackURL: "https://owned.invalid/api/chat-webhooks/owned/slack",
|
||||
};
|
||||
expect(validSlackHumanConnect(retry)).toBe(true);
|
||||
for (const bad of [
|
||||
{ ...retry, appId: "other" },
|
||||
{
|
||||
...retry,
|
||||
callbackURL: "http://owned.invalid/api/chat-webhooks/owned/slack",
|
||||
},
|
||||
{
|
||||
...retry,
|
||||
callbackURL:
|
||||
"https://user:key@owned.invalid/api/chat-webhooks/owned/slack",
|
||||
},
|
||||
{ ...retry, callbackURL: "https://owned.invalid/other" },
|
||||
])
|
||||
expect(validSlackHumanConnect(bad)).toBe(false);
|
||||
const receipt = {
|
||||
processIsolation: true,
|
||||
testRunnerAttached: false,
|
||||
tracingEnabled: false,
|
||||
videoEnabled: false,
|
||||
verifiedWorkspaceId: input.workspaceId,
|
||||
verifiedHumanId: input.senderUserId,
|
||||
callbackRetryRequested: true,
|
||||
};
|
||||
expect(validSlackHumanReceipt(receipt, retry)).toBe(true);
|
||||
expect(validSlackHumanReceipt(receipt, input)).toBe(false);
|
||||
expect(
|
||||
validSlackHumanReceipt({ ...receipt, commandSubmitted: true }, retry),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -6,8 +6,34 @@ export interface SlackHumanConnectInput {
|
||||
senderUserId: string;
|
||||
command: string;
|
||||
session: StorageState;
|
||||
operation?: "connect" | "retry-callback";
|
||||
appId?: string;
|
||||
callbackURL?: string;
|
||||
}
|
||||
export function validSlackHumanConnect(input: SlackHumanConnectInput) {
|
||||
if (input.operation === "retry-callback") {
|
||||
try {
|
||||
const url = new URL(input.callbackURL ?? "");
|
||||
if (
|
||||
!/^A[A-Z0-9]+$/.test(input.appId ?? "") ||
|
||||
url.protocol !== "https:" ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
!/^\/api\/chat-webhooks\/[a-zA-Z0-9_-]+\/slack$/.test(url.pathname)
|
||||
)
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
(input.operation && input.operation !== "connect") ||
|
||||
input.appId ||
|
||||
input.callbackURL
|
||||
)
|
||||
return false;
|
||||
|
||||
if (
|
||||
!/^T[A-Z0-9]+$/.test(input.workspaceId) ||
|
||||
!/^C[A-Z0-9]+$|^G[A-Z0-9]+$/.test(input.channelId) ||
|
||||
@@ -70,11 +96,15 @@ export interface SlackHumanConnectReceipt {
|
||||
videoEnabled: false;
|
||||
verifiedWorkspaceId: string;
|
||||
verifiedHumanId: string;
|
||||
commandSubmitted: true;
|
||||
commandSubmitted?: true;
|
||||
callbackRetryRequested?: true;
|
||||
}
|
||||
export function validSlackHumanReceipt(
|
||||
value: unknown,
|
||||
input: Pick<SlackHumanConnectInput, "workspaceId" | "senderUserId">,
|
||||
input: Pick<
|
||||
SlackHumanConnectInput,
|
||||
"workspaceId" | "senderUserId" | "operation"
|
||||
>,
|
||||
) {
|
||||
const r = value as Partial<SlackHumanConnectReceipt> | undefined;
|
||||
return (
|
||||
@@ -88,6 +118,7 @@ export function validSlackHumanReceipt(
|
||||
"verifiedWorkspaceId",
|
||||
"verifiedHumanId",
|
||||
"commandSubmitted",
|
||||
"callbackRetryRequested",
|
||||
].includes(key),
|
||||
) &&
|
||||
r.processIsolation === true &&
|
||||
@@ -96,6 +127,8 @@ export function validSlackHumanReceipt(
|
||||
r.videoEnabled === false &&
|
||||
r.verifiedWorkspaceId === input.workspaceId &&
|
||||
r.verifiedHumanId === input.senderUserId &&
|
||||
r.commandSubmitted === true
|
||||
(input.operation === "retry-callback"
|
||||
? r.callbackRetryRequested === true && r.commandSubmitted === undefined
|
||||
: r.commandSubmitted === true && r.callbackRetryRequested === undefined)
|
||||
);
|
||||
}
|
||||
@@ -81,6 +81,52 @@ async function main() {
|
||||
"Actual signed-in Slack human does not match selected provider identity",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
if (input.operation === "retry-callback") {
|
||||
await page.goto(
|
||||
`https://api.slack.com/apps/${input.appId}/event-subscriptions`,
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
const appURL = new URL(page.url());
|
||||
if (
|
||||
appURL.origin !== "https://api.slack.com" ||
|
||||
appURL.pathname !== `/apps/${input.appId}/event-subscriptions`
|
||||
)
|
||||
throw new Error(
|
||||
"Slack app settings did not retain the exact authorized app",
|
||||
);
|
||||
const field = page.getByLabel("Request URL", { exact: true });
|
||||
await field.waitFor();
|
||||
if ((await field.inputValue()) !== input.callbackURL)
|
||||
throw new Error(
|
||||
"Slack's saved Request URL differs from the actual owned Paperclip endpoint",
|
||||
);
|
||||
const retry = page.getByRole("button", { name: "Retry", exact: true });
|
||||
if (await retry.isVisible()) await retry.click();
|
||||
else {
|
||||
await field.fill("");
|
||||
await field.fill(input.callbackURL!);
|
||||
}
|
||||
await page.getByText("Verified", { exact: true }).waitFor();
|
||||
if ((await field.inputValue()) !== input.callbackURL)
|
||||
throw new Error(
|
||||
"Slack callback validation changed the saved destination",
|
||||
);
|
||||
const save = page.getByRole("button", {
|
||||
name: "Save Changes",
|
||||
exact: true,
|
||||
});
|
||||
if ((await save.isVisible()) && (await save.isEnabled()))
|
||||
await save.click();
|
||||
return {
|
||||
processIsolation: true,
|
||||
testRunnerAttached: false,
|
||||
tracingEnabled: false,
|
||||
videoEnabled: false,
|
||||
verifiedWorkspaceId: input.workspaceId,
|
||||
verifiedHumanId: actualUserId,
|
||||
callbackRetryRequested: true,
|
||||
};
|
||||
}
|
||||
const composer = page.getByRole("textbox", {
|
||||
name: new RegExp(`^Message #?${input.channelName}$`, "i"),
|
||||
});
|
||||
|
||||
@@ -39,6 +39,9 @@ export async function privateSlackHumanConnect(
|
||||
channelName: input.channelName,
|
||||
senderUserId: input.senderUserId,
|
||||
command: input.command,
|
||||
operation: input.operation,
|
||||
appId: input.appId,
|
||||
callbackURL: input.callbackURL,
|
||||
session,
|
||||
};
|
||||
if (!validSlackHumanConnect(request))
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
gradeSlackRepair,
|
||||
slackRepairGuidance,
|
||||
} from "./external-slack-repair.js";
|
||||
import { slackAuthorizationReceipt } from "./external-slack-authorization.js";
|
||||
function valid() {
|
||||
const run = (id: string, issueId: string) => ({
|
||||
id,
|
||||
companyId: "company",
|
||||
agentId: "ceo",
|
||||
runtimeMode: "native",
|
||||
status: "succeeded",
|
||||
contextSnapshot: { issueId },
|
||||
}),
|
||||
query =
|
||||
"Please confirm you can reply in this same repaired Slack conversation. Do not start a separate task.",
|
||||
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.000004",
|
||||
},
|
||||
};
|
||||
return {
|
||||
scenarioId: "chat-channels.repair-endpoint",
|
||||
companyId: "company",
|
||||
leadId: "ceo",
|
||||
origin: "http://127.0.0.1:3100",
|
||||
reply: "Slack is reconnected and replies work in that thread.",
|
||||
state: {
|
||||
initialRunId: "initial",
|
||||
canonicalIssueId: "canonical",
|
||||
noSetupNative: true,
|
||||
noSetupConversation: true,
|
||||
failedSignedProviderDelivery: true,
|
||||
agentReconnectStatus: 403,
|
||||
directedEndpointId: "endpoint",
|
||||
endpointId: "endpoint",
|
||||
currentHumanId: "person",
|
||||
workspaceId: "T123",
|
||||
senderUserId: "U123",
|
||||
botUserId: "UBOT",
|
||||
threadId: "1000000000.000001",
|
||||
verificationClearedAfterReconnect: true,
|
||||
repairVerificationStarted: Date.parse("2031-02-14T10:00:00Z"),
|
||||
},
|
||||
phaseReplies: [
|
||||
{
|
||||
runId: "initial",
|
||||
body: "Slack authorization expired. You must reconnect this account through its supported human setup.",
|
||||
},
|
||||
{
|
||||
runId: "final",
|
||||
body: "Slack is reconnected and replies work in that thread.",
|
||||
},
|
||||
],
|
||||
runs: [
|
||||
run("initial", "canonical"),
|
||||
run("slack-run", "slack-source"),
|
||||
run("final", "canonical"),
|
||||
],
|
||||
humanQueries: [
|
||||
"Slack replies stopped working; reconnect the account.",
|
||||
"Slack replies stopped working; reconnect the account.",
|
||||
],
|
||||
tasks: [],
|
||||
projects: [],
|
||||
goals: [],
|
||||
comments: [],
|
||||
documents: [],
|
||||
revisions: [],
|
||||
activity: [],
|
||||
links: [],
|
||||
connections: [],
|
||||
profiles: [],
|
||||
secrets: [],
|
||||
endpoints: [{ id: "endpoint" }],
|
||||
interactions: [],
|
||||
repair: {
|
||||
endpoint: {
|
||||
id: "endpoint",
|
||||
companyId: "company",
|
||||
assignedAgentId: "ceo",
|
||||
providerAccountId: "T123",
|
||||
status: "active",
|
||||
setup: { step: "complete", webhookVerifiedAt: "2031-02-14T10:00:01Z" },
|
||||
allowUnlinkedPeople: false,
|
||||
},
|
||||
oldValid: { ok: true, identityMatches: true, code: "ok" },
|
||||
expired: { ok: false, identityMatches: false, code: "token_expired" },
|
||||
fresh: { ok: true, identityMatches: true, code: "ok" },
|
||||
failedActivity: [
|
||||
{
|
||||
id: "failed",
|
||||
kind: "delivery",
|
||||
status: "failed",
|
||||
summary: "message failed",
|
||||
detail: "Slack API token_expired",
|
||||
},
|
||||
],
|
||||
credentialUi: {
|
||||
processIsolation: true,
|
||||
testRunnerAttached: false,
|
||||
tracingEnabled: false,
|
||||
videoEnabled: false,
|
||||
apiStepRecorder: false,
|
||||
endpointId: "endpoint",
|
||||
slackCredentialUiVisible: true,
|
||||
humanSubmittedSecurely: true,
|
||||
storageClean: true,
|
||||
credentialUiRejected: false,
|
||||
},
|
||||
retryUi: {
|
||||
processIsolation: true,
|
||||
testRunnerAttached: false,
|
||||
tracingEnabled: false,
|
||||
videoEnabled: false,
|
||||
callbackRetryRequested: true,
|
||||
verifiedHumanId: "U123",
|
||||
verifiedWorkspaceId: "T123",
|
||||
},
|
||||
wizardUi: {
|
||||
processIsolation: true,
|
||||
testRunnerAttached: false,
|
||||
tracingEnabled: false,
|
||||
videoEnabled: false,
|
||||
apiStepRecorder: false,
|
||||
endpointId: "endpoint",
|
||||
activationUiVisible: true,
|
||||
endpointActive: true,
|
||||
identityWasAlreadyLinked: true,
|
||||
paperclipUserId: "person",
|
||||
},
|
||||
principalUserId: "person",
|
||||
connectAt: "2031-02-14T10:00:02Z",
|
||||
source,
|
||||
sourceBound: true,
|
||||
runId: "slack-run",
|
||||
reply: {
|
||||
id: "reply",
|
||||
createdByRunId: "slack-run",
|
||||
authorAgentId: "ceo",
|
||||
body: "I can reply in this Slack thread.",
|
||||
},
|
||||
messages: [
|
||||
{ ts: "1000000000.000004", user: "U123", text: query },
|
||||
{
|
||||
ts: "1000000000.000005",
|
||||
user: "UBOT",
|
||||
text: "I can reply in this Slack thread.",
|
||||
},
|
||||
],
|
||||
failedProviderCapture: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
it("accepts real old-valid→expired proof, actual same-person repair UI and subsequent same-thread native reply", () =>
|
||||
expect(gradeSlackRepair(valid()).passed).toBe(true));
|
||||
it("rejects wrong starting conditions, refusal-only repair, healthy-auth-only, stale verification and wrong thread/run", () => {
|
||||
for (const mutate of [
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.oldValid.ok = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.oldValid.identityMatches = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.expired.code = "invalid_auth";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.fresh.identityMatches = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.state.noSetupNative = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.state.failedSignedProviderDelivery = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.failedActivity = [];
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.phaseReplies[0]!.body =
|
||||
"I've already fixed Slack. You can reconnect later.";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.state.directedEndpointId = "another";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.state.agentReconnectStatus = 200;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.credentialUi.credentialUiRejected = true;
|
||||
o.repair.credentialUi.humanSubmittedSecurely = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.retryUi.callbackRetryRequested = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.state.verificationClearedAfterReconnect = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.endpoint.setup.webhookVerifiedAt = "2031-02-14T09:59:00Z";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.wizardUi.activationUiVisible = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.wizardUi.identityWasAlreadyLinked = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.principalUserId = "another";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.sourceBound = false;
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.source.provider.threadId = "other";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.reply.createdByRunId = "old";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.repair.messages = o.repair.messages.slice(0, 1);
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.runs[1]!.contextSnapshot.issueId = "other";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.runs[2]!.contextSnapshot.issueId = "other";
|
||||
},
|
||||
(o: ReturnType<typeof valid>) => {
|
||||
o.phaseReplies[1]!.body = "Slack is not reconnected or ready.";
|
||||
},
|
||||
]) {
|
||||
const o = valid();
|
||||
mutate(o);
|
||||
expect(gradeSlackRepair(o).passed).toBe(false);
|
||||
}
|
||||
expect(gradeSlackRepair(undefined).passed).toBe(false);
|
||||
for (const key of [
|
||||
"oldValid",
|
||||
"expired",
|
||||
"fresh",
|
||||
"credentialUi",
|
||||
"retryUi",
|
||||
"wizardUi",
|
||||
"source",
|
||||
"reply",
|
||||
"messages",
|
||||
]) {
|
||||
const o = valid();
|
||||
delete (o.repair as Record<string, unknown>)[key];
|
||||
expect(gradeSlackRepair(o).passed).toBe(false);
|
||||
}
|
||||
});
|
||||
it("retains truthful human repair instructions but rejects ordinary false completion claims", () => {
|
||||
expect(
|
||||
slackRepairGuidance(
|
||||
"You need to reconnect Slack in settings. I have not repaired it yet.",
|
||||
),
|
||||
).toBe(true);
|
||||
for (const text of [
|
||||
"I've already reconnected Slack; you can check settings.",
|
||||
"Slack replies are now working. You can reconnect it later.",
|
||||
"I have already restored Slack; use settings.",
|
||||
]) {
|
||||
expect(slackRepairGuidance(text)).toBe(false);
|
||||
}
|
||||
});
|
||||
it("projects only closed authorization status and exact designated identity, never unknown account metadata", () => {
|
||||
const binding = {
|
||||
disposable: true as const,
|
||||
resourceIds: ["T123", "UBOT"],
|
||||
credentialEnv: ["PAPERCLIP_CHAT_EVAL_SLACK_BOT"],
|
||||
prerequisites: { workspaceId: "T123", botUserId: "UBOT" },
|
||||
};
|
||||
expect(
|
||||
slackAuthorizationReceipt(
|
||||
{ ok: true, team_id: "T123", user_id: "UBOT" },
|
||||
binding,
|
||||
),
|
||||
).toEqual({ ok: true, identityMatches: true, code: "ok" });
|
||||
expect(
|
||||
slackAuthorizationReceipt({ ok: false, error: "token_expired" }, binding)
|
||||
.code,
|
||||
).toBe("token_expired");
|
||||
expect(
|
||||
slackAuthorizationReceipt(
|
||||
{ ok: true, team_id: "PRIVATEOTHER", user_id: "PRIVATEOTHER" },
|
||||
binding,
|
||||
),
|
||||
).toEqual({ ok: true, identityMatches: false, code: "ok" });
|
||||
expect(
|
||||
slackAuthorizationReceipt(
|
||||
{ ok: false, error: "private-provider-detail" },
|
||||
binding,
|
||||
),
|
||||
).toEqual({ ok: false, identityMatches: false, code: "other" });
|
||||
});
|
||||
@@ -0,0 +1,882 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type {
|
||||
ChatCatalogContext,
|
||||
ChatSlackIngressReceipt,
|
||||
} from "../chat-catalog-contract.js";
|
||||
import type {
|
||||
ChatActivityItem,
|
||||
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,
|
||||
waitForReply,
|
||||
probeAgentPermission,
|
||||
} from "./identity-support.js";
|
||||
import {
|
||||
selectedExternalBinding,
|
||||
selectedExternalCredential,
|
||||
} from "./external-fixtures.js";
|
||||
import {
|
||||
prepareSlackFixture,
|
||||
slackCompanionFields,
|
||||
type SlackMessage,
|
||||
} from "./external-slack-fixture.js";
|
||||
import {
|
||||
privateBrowserEntry,
|
||||
type PrivateBrowserReceipt,
|
||||
} from "./external-private-browser.js";
|
||||
import { privateSlackHumanConnect } from "./external-slack-human.js";
|
||||
import {
|
||||
probeSlackAuthorization,
|
||||
type SlackAuthorizationReceipt,
|
||||
} from "./external-slack-authorization.js";
|
||||
export const slackRepairFields = [
|
||||
...slackCompanionFields,
|
||||
"slackBrowserSessionEnv",
|
||||
"botTokenExpiresAt",
|
||||
"repairBotTokenEnv",
|
||||
] as const;
|
||||
type Fixture = Awaited<ReturnType<typeof prepareSlackFixture>>;
|
||||
interface Local {
|
||||
fixture: Fixture;
|
||||
oldValid: SlackAuthorizationReceipt;
|
||||
expired: SlackAuthorizationReceipt;
|
||||
fresh: SlackAuthorizationReceipt;
|
||||
failedActivity: ChatActivityItem[];
|
||||
failedMessageId: string;
|
||||
credentialUi?: PrivateBrowserReceipt;
|
||||
wizardUi?: PrivateBrowserReceipt;
|
||||
source?: ChatSlackIngressReceipt;
|
||||
runId?: string;
|
||||
reply?: IssueComment;
|
||||
messages?: SlackMessage[];
|
||||
connectAt?: string | null;
|
||||
retryUi?: import("./external-slack-human-contract.js").SlackHumanConnectReceipt;
|
||||
}
|
||||
const fixtures = new WeakMap<ChatCatalogContext, Local>();
|
||||
function owned(ctx: ChatCatalogContext) {
|
||||
const value = fixtures.get(ctx);
|
||||
if (!value) throw new Error("Actual expired Slack fixture missing");
|
||||
return value;
|
||||
}
|
||||
export function slackRepairGuidance(reply: string) {
|
||||
return (
|
||||
/slack/i.test(reply) &&
|
||||
/reconnect|repair|re.authori[sz]|authorization.*expired|expired.*authorization/i.test(
|
||||
reply,
|
||||
) &&
|
||||
/you|human|admin|operator|settings|setup/i.test(reply) &&
|
||||
!reply
|
||||
.split(/[.!?;\n]+|\bbut\b/i)
|
||||
.some(
|
||||
(clause) =>
|
||||
!/\b(?:not|cannot|can't|before|once|after|until|will|would|need|must)\b/i.test(
|
||||
clause,
|
||||
) &&
|
||||
(/\b(?:I|we)(?:['’]ve| have)?\s+(?:already\s+)?(?:reconnected|fixed|repaired|restored)\b/i.test(
|
||||
clause,
|
||||
) ||
|
||||
/\b(?:slack|connection|replies)\s+(?:is|are)\s+(?:already\s+|now\s+)?(?:ready|working|repaired|restored)\b/i.test(
|
||||
clause,
|
||||
)),
|
||||
)
|
||||
);
|
||||
}
|
||||
interface Observation extends ExternalObservation {
|
||||
repair: {
|
||||
endpoint: ChatEndpoint;
|
||||
oldValid: SlackAuthorizationReceipt;
|
||||
expired: SlackAuthorizationReceipt;
|
||||
fresh: SlackAuthorizationReceipt;
|
||||
failedActivity: ChatActivityItem[];
|
||||
credentialUi?: PrivateBrowserReceipt;
|
||||
wizardUi?: PrivateBrowserReceipt;
|
||||
source?: ChatSlackIngressReceipt;
|
||||
sourceBound: boolean;
|
||||
runId?: string;
|
||||
reply?: IssueComment;
|
||||
messages: SlackMessage[];
|
||||
connectAt?: string | null;
|
||||
retryUi?: import("./external-slack-human-contract.js").SlackHumanConnectReceipt;
|
||||
principalUserId?: string | null;
|
||||
failedProviderCapture: boolean;
|
||||
};
|
||||
}
|
||||
export function gradeSlackRepair(value: unknown) {
|
||||
const c = checks(),
|
||||
o = externalObservation(value) as Observation | undefined,
|
||||
t = o?.repair;
|
||||
c.add(
|
||||
"real-expired-authorization-repair-evidence",
|
||||
!!o &&
|
||||
!!t &&
|
||||
!!t.endpoint &&
|
||||
Array.isArray(t.failedActivity) &&
|
||||
Array.isArray(t.messages),
|
||||
"Actual old-valid→token_expired proof, failed signed intake, real human reconnect and new delivery evidence are required.",
|
||||
);
|
||||
if (
|
||||
!o ||
|
||||
!t ||
|
||||
!t.endpoint ||
|
||||
!Array.isArray(t.failedActivity) ||
|
||||
!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),
|
||||
s = t.source,
|
||||
run = o.runs.find((row) => row.id === t.runId),
|
||||
e = t.endpoint;
|
||||
c.add(
|
||||
"qualified-real-expiry-not-invalid-fixture",
|
||||
t.oldValid?.ok === true &&
|
||||
t.oldValid.identityMatches === true &&
|
||||
t.expired?.ok === false &&
|
||||
t.expired.code === "token_expired" &&
|
||||
t.fresh?.ok === true &&
|
||||
t.fresh.identityMatches === true &&
|
||||
o.state.noSetupNative === true &&
|
||||
o.state.noSetupConversation === true &&
|
||||
o.state.failedSignedProviderDelivery === true &&
|
||||
t.failedActivity.some(
|
||||
(row) =>
|
||||
row.kind === "delivery" &&
|
||||
["failed", "retry", "pending"].includes(row.status) &&
|
||||
/expired|invalid.auth|authentication|authorization|token/i.test(
|
||||
`${row.detail ?? ""} ${row.summary}`,
|
||||
),
|
||||
),
|
||||
"The actual previously working selected credential naturally expires; an independently valid same-bot repair credential exists, and a genuine signed delivery fails without pre-creating requested work.",
|
||||
);
|
||||
c.add(
|
||||
"board-only-correct-target-human-repair",
|
||||
!!first &&
|
||||
slackRepairGuidance(first.body) &&
|
||||
o.state.agentReconnectStatus === 403 &&
|
||||
o.state.directedEndpointId === e.id &&
|
||||
e.id === o.state.endpointId &&
|
||||
e.companyId === o.companyId &&
|
||||
e.assignedAgentId === o.leadId,
|
||||
"The actual first phase truthfully directs this endpoint's supported human repair; an agent cannot reconnect it or claim it was repaired before the human.",
|
||||
);
|
||||
c.add(
|
||||
"actual-reconnect-and-activation-ui",
|
||||
t.credentialUi?.endpointId === e.id &&
|
||||
t.credentialUi.slackCredentialUiVisible === true &&
|
||||
t.credentialUi.humanSubmittedSecurely === true &&
|
||||
t.credentialUi.storageClean === true &&
|
||||
t.credentialUi.processIsolation === true &&
|
||||
t.credentialUi.testRunnerAttached === false &&
|
||||
t.credentialUi.tracingEnabled === false &&
|
||||
t.credentialUi.videoEnabled === false &&
|
||||
t.credentialUi.apiStepRecorder === false &&
|
||||
t.credentialUi.credentialUiRejected !== true &&
|
||||
t.retryUi?.callbackRetryRequested === true &&
|
||||
t.retryUi.processIsolation === true &&
|
||||
t.retryUi.testRunnerAttached === false &&
|
||||
t.retryUi.tracingEnabled === false &&
|
||||
t.retryUi.videoEnabled === false &&
|
||||
t.retryUi.verifiedHumanId === o.state.senderUserId &&
|
||||
t.retryUi.verifiedWorkspaceId === o.state.workspaceId &&
|
||||
o.state.verificationClearedAfterReconnect === true &&
|
||||
!!e.setup.webhookVerifiedAt &&
|
||||
Date.parse(e.setup.webhookVerifiedAt) >=
|
||||
Number(o.state.repairVerificationStarted) - 1000 &&
|
||||
t.wizardUi?.endpointId === e.id &&
|
||||
t.wizardUi.activationUiVisible === true &&
|
||||
t.wizardUi.endpointActive === true &&
|
||||
t.wizardUi.identityWasAlreadyLinked === true &&
|
||||
t.wizardUi.processIsolation === true &&
|
||||
t.wizardUi.testRunnerAttached === false &&
|
||||
t.wizardUi.tracingEnabled === false &&
|
||||
t.wizardUi.videoEnabled === false &&
|
||||
t.wizardUi.apiStepRecorder === false &&
|
||||
t.wizardUi.paperclipUserId === o.state.currentHumanId &&
|
||||
t.principalUserId === o.state.currentHumanId &&
|
||||
!!t.connectAt &&
|
||||
e.status === "active" &&
|
||||
e.providerAccountId === o.state.workspaceId &&
|
||||
e.setup.step === "complete" &&
|
||||
!!e.setup.webhookVerifiedAt &&
|
||||
e.allowUnlinkedPeople === false,
|
||||
"The real protected Reconnect form verifies the same bot and a genuine new human connect receipt enables normal Continue/Done; the original person's identity and routing policy remain intact.",
|
||||
);
|
||||
c.add(
|
||||
"new-correct-thread-native-reply-after-repair",
|
||||
!!s &&
|
||||
t.sourceBound &&
|
||||
s.issueId !== o.state.canonicalIssueId &&
|
||||
s.provider.threadId === o.state.threadId &&
|
||||
!!run &&
|
||||
run.companyId === o.companyId &&
|
||||
run.agentId === o.leadId &&
|
||||
run.runtimeMode === "native" &&
|
||||
run.status === "succeeded" &&
|
||||
run.contextSnapshot?.issueId === s.issueId &&
|
||||
t.reply?.createdByRunId === run.id &&
|
||||
t.reply.authorAgentId === o.leadId &&
|
||||
!!t.reply.body.trim() &&
|
||||
t.messages.some(
|
||||
(row) =>
|
||||
row.ts === s.provider.canonicalMessageId &&
|
||||
row.text === s.query &&
|
||||
row.user === o.state.senderUserId,
|
||||
) &&
|
||||
t.messages.some(
|
||||
(row) =>
|
||||
row.user === o.state.botUserId &&
|
||||
row.ts > s.provider.canonicalMessageId &&
|
||||
!!row.text?.trim(),
|
||||
),
|
||||
"Healthy auth alone never proves repair: a newly signed message after actual human repair must persist in the intended thread, run natively and publish a real reply back there.",
|
||||
);
|
||||
c.add(
|
||||
"separate-honest-repair-confirmation",
|
||||
!!last &&
|
||||
/slack/i.test(last.body) &&
|
||||
/working|repaired|restored|reconnected|ready/i.test(last.body) &&
|
||||
!/(?:not|isn['’]t)\s+(?:yet\s+)?(?:working|repaired|restored|reconnected|ready)/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.runId ||
|
||||
(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,
|
||||
"The later canonical reply belongs to its own run after actual new delivery; no replacement endpoint, indefinite retry or unrelated native work substitutes for repairing this endpoint.",
|
||||
);
|
||||
return c.result();
|
||||
}
|
||||
async function send(ctx: ChatCatalogContext, f: Fixture, query: string) {
|
||||
f.relay.expectCanonical(query, f.threadId);
|
||||
const start = f.provider.receipts.length,
|
||||
result = await f.provider.call<{
|
||||
ok: true;
|
||||
channel: string;
|
||||
ts: string;
|
||||
message: { user: string; text: 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 (
|
||||
result.channel !== f.binding.prerequisites.channelId ||
|
||||
result.message.user !== f.binding.prerequisites.senderUserId ||
|
||||
result.message.text !== query ||
|
||||
result.message.thread_ts !== f.threadId
|
||||
)
|
||||
throw new Error(
|
||||
"Actual Slack repair probe changed the designated human/thread/text",
|
||||
);
|
||||
await pollUntil({
|
||||
label: "genuine signed Slack repair probe",
|
||||
deadlineAt: Date.now() + 30000,
|
||||
load: async () => f.relay.receipt(),
|
||||
accept: (row) => row.canonicalDeliveryCaptured,
|
||||
});
|
||||
return { messageId: result.ts, calls: f.provider.receipts.length - start };
|
||||
}
|
||||
export const slackRepairExternalCases = [
|
||||
definition(
|
||||
"chat-channels.repair-endpoint",
|
||||
async (ctx) => {
|
||||
const binding = selectedExternalBinding(ctx),
|
||||
expires = Date.parse(binding.prerequisites.botTokenExpiresAt ?? "");
|
||||
if (
|
||||
!Number.isFinite(expires) ||
|
||||
expires < Date.now() + 120000 ||
|
||||
expires > Date.now() + 480000
|
||||
)
|
||||
throw new Error(
|
||||
"Setup blocker: a designated rotating Slack bot credential must still be valid and naturally expire in the bounded two-to-eight-minute qualification window",
|
||||
);
|
||||
if (
|
||||
selectedExternalCredential(ctx, binding, "botTokenEnv") ===
|
||||
selectedExternalCredential(ctx, binding, "repairBotTokenEnv")
|
||||
)
|
||||
throw new Error(
|
||||
"Setup blocker: expiring and fresh repair Slack credentials must be distinct actual tokens",
|
||||
);
|
||||
const oldValid = await probeSlackAuthorization(
|
||||
ctx,
|
||||
binding,
|
||||
"botTokenEnv",
|
||||
),
|
||||
fresh = await probeSlackAuthorization(
|
||||
ctx,
|
||||
binding,
|
||||
"repairBotTokenEnv",
|
||||
);
|
||||
if (
|
||||
!oldValid.ok ||
|
||||
!oldValid.identityMatches ||
|
||||
!fresh.ok ||
|
||||
!fresh.identityMatches
|
||||
)
|
||||
throw new Error(
|
||||
"Setup blocker: actual currently valid old and fresh same-bot Slack credentials are required",
|
||||
);
|
||||
const f = await prepareSlackFixture(ctx, {
|
||||
observeWithRepairCredential: true,
|
||||
useExistingAppCommand: true,
|
||||
});
|
||||
try {
|
||||
// Invalidate the qualified SDK generation while credentials are genuinely
|
||||
// valid; after natural expiry the next intake must exercise fresh auth reads.
|
||||
await ctx.input.api.post(`/api/chat-endpoints/${f.endpoint.id}/setup`, {
|
||||
action: "pause",
|
||||
});
|
||||
await ctx.input.api.post(`/api/chat-endpoints/${f.endpoint.id}/setup`, {
|
||||
action: "resume",
|
||||
});
|
||||
const expired = await pollUntil({
|
||||
label: "actual natural Slack token expiry",
|
||||
deadlineAt: expires + 30000,
|
||||
intervalMs: 5000,
|
||||
load: () => probeSlackAuthorization(ctx, binding, "botTokenEnv"),
|
||||
accept: (row) => !row.ok && row.code === "token_expired",
|
||||
reject: (row) =>
|
||||
!row.ok && row.code !== "token_expired"
|
||||
? "Setup blocker: selected Slack token failed for a reason other than the required natural expiry"
|
||||
: undefined,
|
||||
});
|
||||
const query =
|
||||
"Please confirm this Slack connection can receive a message.",
|
||||
failed = await send(ctx, f, query);
|
||||
const failedActivity = await pollUntil({
|
||||
label:
|
||||
"actual expired Slack credential failed intake without native work",
|
||||
deadlineAt: Date.now() + 60000,
|
||||
intervalMs: 1000,
|
||||
load: async () => ({
|
||||
activity: await ctx.input.api.get<ChatActivityItem[]>(
|
||||
`/api/chat-endpoints/${f.endpoint.id}/activity`,
|
||||
),
|
||||
runs: await ctx.allRuns(),
|
||||
}),
|
||||
accept: (value) =>
|
||||
value.runs.length === 0 &&
|
||||
value.activity.some(
|
||||
(row) =>
|
||||
row.kind === "delivery" &&
|
||||
["failed", "retry", "pending"].includes(row.status) &&
|
||||
/expired|invalid.auth|authentication|authorization|token/i.test(
|
||||
`${row.detail ?? ""} ${row.summary}`,
|
||||
),
|
||||
),
|
||||
reject: (value) =>
|
||||
value.runs.length
|
||||
? "Setup blocker: the selected expired token did not qualify a failed-intake/no-native starting context"
|
||||
: undefined,
|
||||
});
|
||||
const conversations = await ctx.input.api.get<ChatConversation[]>(
|
||||
`/api/chat-endpoints/${f.endpoint.id}/conversations`,
|
||||
);
|
||||
if (conversations.length)
|
||||
throw new Error(
|
||||
"Setup blocker: expired Slack intake already created a conversation instead of the required failed starting context",
|
||||
);
|
||||
fixtures.set(ctx, {
|
||||
fixture: f,
|
||||
oldValid,
|
||||
expired,
|
||||
fresh,
|
||||
failedActivity: failedActivity.activity,
|
||||
failedMessageId: failed.messageId,
|
||||
});
|
||||
const chat = await openStandingChat(ctx);
|
||||
await ctx.input.api.patch(`/api/issues/${chat.id}`, {
|
||||
description: `The existing Slack endpoint is ${f.endpoint.id}, assigned to you. A real preflight delivery in its intended Slack conversation failed after its previously valid bot authorization naturally expired. The original thread remains ${f.threadId}; there is no new authorized conversation. Supported repair is /${ctx.input.fixtures.company.issuePrefix}/apps/chat/connect?provider=slack&purpose=chat&resume=${f.endpoint.id}&reconnect=1.`,
|
||||
});
|
||||
return {
|
||||
endpointId: f.endpoint.id,
|
||||
canonicalIssueId: chat.id,
|
||||
currentHumanId: (
|
||||
await ctx.input.api.get<{ user: { id: string } }>(
|
||||
"/api/auth/get-session",
|
||||
)
|
||||
).user.id,
|
||||
workspaceId: binding.prerequisites.workspaceId,
|
||||
senderUserId: binding.prerequisites.senderUserId,
|
||||
botUserId: binding.prerequisites.botUserId,
|
||||
threadId: f.threadId,
|
||||
noSetupNative: failedActivity.runs.length === 0,
|
||||
noSetupConversation: conversations.length === 0,
|
||||
failedSignedProviderDelivery:
|
||||
f.relay.receipt().canonicalDeliveryCaptured,
|
||||
};
|
||||
} catch (error) {
|
||||
await f.cleanup();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
gradeSlackRepair,
|
||||
{
|
||||
authentication: "human-session",
|
||||
expectedRunCount: 3,
|
||||
minimumRunCount: 3,
|
||||
expectedHumanMessageCount: 2,
|
||||
turnTimeoutMs: 120000,
|
||||
attemptTimeoutMs: 1500000,
|
||||
externalInput: { provider: "slack", fields: slackRepairFields },
|
||||
sourceFiles: [
|
||||
"chat-catalog-domains/external-slack-repair.ts",
|
||||
"chat-catalog-domains/external-slack-authorization.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",
|
||||
"../../server/src/services/chat-channels/configuration.ts",
|
||||
"../../server/src/services/chat-channels/slack/credentials.ts",
|
||||
"../../server/src/services/chat-channels/slack/commands.ts",
|
||||
"../../server/src/services/chat-channels.ts",
|
||||
"chat-scenarios/chat-channels.yaml",
|
||||
],
|
||||
async verifySetup(ctx, state) {
|
||||
const local = owned(ctx),
|
||||
old = await probeSlackAuthorization(
|
||||
ctx,
|
||||
local.fixture.binding,
|
||||
"botTokenEnv",
|
||||
),
|
||||
fresh = await probeSlackAuthorization(
|
||||
ctx,
|
||||
local.fixture.binding,
|
||||
"repairBotTokenEnv",
|
||||
),
|
||||
ready =
|
||||
old.code === "token_expired" &&
|
||||
fresh.ok &&
|
||||
fresh.identityMatches &&
|
||||
(await ctx.allRuns()).length === 0;
|
||||
return {
|
||||
passed: ready,
|
||||
checks: [
|
||||
{
|
||||
id: "actual-expired-authorization-with-fresh-same-bot-repair",
|
||||
passed: ready,
|
||||
detail:
|
||||
"Natural provider token_expired is retained independently from the originally valid credential and no native work was admitted during qualification.",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async act(ctx, state) {
|
||||
await waitForReply(ctx);
|
||||
const local = owned(ctx),
|
||||
f = local.fixture,
|
||||
o = await observeExternal(ctx, state),
|
||||
first = o.phaseReplies.at(-1);
|
||||
if (!first || !slackRepairGuidance(first.body))
|
||||
throw new Error(
|
||||
"Unsupported or falsely completed repair advice cannot be rescued by the human fixture",
|
||||
);
|
||||
state.initialRunId = first.runId;
|
||||
const href = o.links.find((link) => {
|
||||
try {
|
||||
const u = new URL(link, o.origin);
|
||||
return (
|
||||
u.origin === o.origin &&
|
||||
u.pathname ===
|
||||
`/${ctx.input.fixtures.company.issuePrefix}/apps/chat/connect` &&
|
||||
u.searchParams.get("provider") === "slack" &&
|
||||
u.searchParams.get("resume") === f.endpoint.id &&
|
||||
u.searchParams.get("reconnect") === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!href)
|
||||
throw new Error(
|
||||
"Actual first phase must direct this exact existing endpoint's supported human Reconnect path",
|
||||
);
|
||||
state.directedEndpointId = f.endpoint.id;
|
||||
state.agentReconnectStatus = await probeAgentPermission(
|
||||
ctx,
|
||||
`/api/chat-endpoints/${f.endpoint.id}/setup`,
|
||||
"POST",
|
||||
{ action: "reconnect" },
|
||||
);
|
||||
const repairURL = new URL(href, o.origin);
|
||||
repairURL.searchParams.set("purpose", "chat");
|
||||
repairURL.searchParams.set("stage", "credentials");
|
||||
local.credentialUi = await privateBrowserEntry({
|
||||
mode: "slack-endpoint-reconnect",
|
||||
companyId: ctx.input.fixtures.company.id,
|
||||
slackSetup: {
|
||||
endpointId: f.endpoint.id,
|
||||
agentId: ctx.input.fixtures.agent.id,
|
||||
workspaceId: f.binding.prerequisites.workspaceId!,
|
||||
signingSecret: selectedExternalCredential(
|
||||
ctx,
|
||||
f.binding,
|
||||
"signingSecretEnv",
|
||||
),
|
||||
},
|
||||
baseURL: o.origin,
|
||||
setupURL: repairURL.href,
|
||||
providerURL: o.origin,
|
||||
credential: selectedExternalCredential(
|
||||
ctx,
|
||||
f.binding,
|
||||
"repairBotTokenEnv",
|
||||
),
|
||||
cookies: await ctx.input.page.context().cookies(o.origin),
|
||||
});
|
||||
if (local.credentialUi.credentialUiRejected) {
|
||||
await ctx.input.evidence(
|
||||
"chat-catalog-Slack-repair-credential-UI-rejected.json",
|
||||
local.credentialUi,
|
||||
);
|
||||
throw new Error(
|
||||
"Product Slack Reconnect form rejected the actual selected rotating bot-token format; repair is incomplete",
|
||||
);
|
||||
}
|
||||
const configured = await ctx.input.api.get<ChatEndpoint>(
|
||||
`/api/chat-endpoints/${f.endpoint.id}`,
|
||||
),
|
||||
repairVerificationStarted = Date.now();
|
||||
if (!configured.setup.webhookUrl || !configured.setup.command)
|
||||
throw new Error(
|
||||
"Actual saved Slack app callback/command missing during repair",
|
||||
);
|
||||
const selectedChannel = await f.provider.call<{
|
||||
ok: true;
|
||||
channel: { id: string; name: string };
|
||||
}>("bot", "conversations.info", {
|
||||
channel: f.binding.prerequisites.channelId,
|
||||
});
|
||||
if (selectedChannel.channel.id !== f.binding.prerequisites.channelId)
|
||||
throw new Error("Selected repair callback channel changed");
|
||||
state.repairVerificationStarted = repairVerificationStarted;
|
||||
state.verificationClearedAfterReconnect =
|
||||
!configured.setup.webhookVerifiedAt;
|
||||
local.retryUi = await privateSlackHumanConnect(
|
||||
{
|
||||
operation: "retry-callback",
|
||||
appId: f.binding.prerequisites.appId!,
|
||||
callbackURL: configured.setup.webhookUrl,
|
||||
workspaceId: f.binding.prerequisites.workspaceId!,
|
||||
channelId: f.binding.prerequisites.channelId!,
|
||||
channelName: selectedChannel.channel.name,
|
||||
senderUserId: f.binding.prerequisites.senderUserId!,
|
||||
command: configured.setup.command,
|
||||
sessionJson: selectedExternalCredential(
|
||||
ctx,
|
||||
f.binding,
|
||||
"slackBrowserSessionEnv",
|
||||
),
|
||||
},
|
||||
ctx.input.privateSecrets!,
|
||||
);
|
||||
await pollUntil({
|
||||
label:
|
||||
"fresh actual provider-signed Slack URL verification after human Retry",
|
||||
deadlineAt: Date.now() + 60000,
|
||||
load: () =>
|
||||
ctx.input.api.get<ChatEndpoint>(
|
||||
`/api/chat-endpoints/${f.endpoint.id}`,
|
||||
),
|
||||
accept: (row) =>
|
||||
!!row.setup.webhookVerifiedAt &&
|
||||
Date.parse(row.setup.webhookVerifiedAt) >=
|
||||
repairVerificationStarted - 1000,
|
||||
});
|
||||
const page = await ctx.input.page.context().newPage();
|
||||
try {
|
||||
await page.goto(
|
||||
`${o.origin}/${ctx.input.fixtures.company.issuePrefix}/apps/chat/connect?provider=slack&purpose=chat&resume=${f.endpoint.id}`,
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
await page
|
||||
.getByRole("heading", {
|
||||
name: "Verify Slack connection",
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
const verified = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname ===
|
||||
`/api/chat-endpoints/${f.endpoint.id}/setup` &&
|
||||
response.request().method() === "POST" &&
|
||||
response.ok(),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Continue", exact: true })
|
||||
.click();
|
||||
const response = await verified;
|
||||
if (response.request().postDataJSON()?.action !== "verify")
|
||||
throw new Error(
|
||||
"Real Slack verification UI submitted another operation",
|
||||
);
|
||||
await response.json();
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
const endpoint = await ctx.input.api.get<ChatEndpoint>(
|
||||
`/api/chat-endpoints/${f.endpoint.id}`,
|
||||
),
|
||||
command = endpoint.setup.command;
|
||||
if (!command)
|
||||
throw new Error("Actual saved Slack app command unavailable");
|
||||
f.relay.expectHumanConnect(command);
|
||||
const channel = await f.provider.call<{
|
||||
ok: true;
|
||||
channel: { id: string; name: string };
|
||||
}>("bot", "conversations.info", {
|
||||
channel: f.binding.prerequisites.channelId,
|
||||
});
|
||||
if (channel.channel.id !== f.binding.prerequisites.channelId)
|
||||
throw new Error("Selected repair channel changed");
|
||||
const since = Date.now();
|
||||
await privateSlackHumanConnect(
|
||||
{
|
||||
workspaceId: f.binding.prerequisites.workspaceId!,
|
||||
channelId: f.binding.prerequisites.channelId!,
|
||||
channelName: channel.channel.name,
|
||||
senderUserId: f.binding.prerequisites.senderUserId!,
|
||||
command,
|
||||
sessionJson: selectedExternalCredential(
|
||||
ctx,
|
||||
f.binding,
|
||||
"slackBrowserSessionEnv",
|
||||
),
|
||||
},
|
||||
ctx.input.privateSecrets!,
|
||||
);
|
||||
await pollUntil({
|
||||
label: "new genuine signed Slack repair connect receipt",
|
||||
deadlineAt: Date.now() + 60000,
|
||||
load: () => f.readPrincipal(),
|
||||
accept: (row) =>
|
||||
f.relay.receipt().humanConnectCaptured &&
|
||||
!!row.lastConnectAt &&
|
||||
Date.parse(row.lastConnectAt) >= since - 1000,
|
||||
});
|
||||
const principal = await f.readPrincipal();
|
||||
local.wizardUi = await privateBrowserEntry({
|
||||
mode: "slack-wizard-resume",
|
||||
companyId: ctx.input.fixtures.company.id,
|
||||
slackIdentity: {
|
||||
endpointId: f.endpoint.id,
|
||||
principalId: f.principalId,
|
||||
userId: String(state.currentHumanId),
|
||||
externalDetail: principal.externalDetail,
|
||||
externalLabel: principal.externalLabel,
|
||||
},
|
||||
baseURL: o.origin,
|
||||
setupURL: `${o.origin}/${ctx.input.fixtures.company.issuePrefix}/apps/chat/connect?provider=slack&purpose=chat&resume=${f.endpoint.id}`,
|
||||
providerURL: o.origin,
|
||||
credential: "finite-Slack-repair-wizard-resume",
|
||||
cookies: await ctx.input.page.context().cookies(o.origin),
|
||||
});
|
||||
local.connectAt = (await f.readPrincipal()).lastConnectAt;
|
||||
f.relay.nextFilePhase();
|
||||
const query =
|
||||
"Please confirm you can reply in this same repaired Slack conversation. Do not start a separate task.",
|
||||
sent = await send(ctx, f, query);
|
||||
local.source = await f.resolveIngress(
|
||||
query,
|
||||
sent.messageId,
|
||||
sent.calls,
|
||||
);
|
||||
const runs = await pollUntil({
|
||||
label: "new same-thread real Slack native after actual repair",
|
||||
deadlineAt: Date.now() + 150000,
|
||||
intervalMs: 1000,
|
||||
load: () => ctx.allRuns(),
|
||||
accept: (rows) =>
|
||||
rows.length === 2 &&
|
||||
rows.some(
|
||||
(row) =>
|
||||
row.contextSnapshot?.issueId === local.source!.issueId &&
|
||||
row.agentId === ctx.input.fixtures.agent.id &&
|
||||
row.status === "succeeded" &&
|
||||
row.runtimeMode === "native",
|
||||
),
|
||||
reject: (rows) =>
|
||||
rows.length > 2 ||
|
||||
rows.some((row) =>
|
||||
["failed", "cancelled", "timed_out"].includes(row.status),
|
||||
)
|
||||
? "Unexpected/failed native instead of repaired Slack delivery"
|
||||
: undefined,
|
||||
});
|
||||
local.runId = 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.reply = comments
|
||||
.filter(
|
||||
(row) =>
|
||||
row.createdByRunId === local.runId &&
|
||||
row.authorAgentId === ctx.input.fixtures.agent.id,
|
||||
)
|
||||
.at(-1);
|
||||
local.messages = await pollUntil({
|
||||
label: "actual new provider-thread reply after Slack repair",
|
||||
deadlineAt: Date.now() + 60000,
|
||||
intervalMs: 2000,
|
||||
load: () => f.readThread(f.threadId),
|
||||
accept: (rows) =>
|
||||
rows.some(
|
||||
(row) =>
|
||||
row.ts > sent.messageId &&
|
||||
row.user === f.binding.prerequisites.botUserId &&
|
||||
!!row.text?.trim(),
|
||||
),
|
||||
});
|
||||
await ctx.input.evidence(
|
||||
"chat-catalog-Slack-actual-expiry-and-repair.json",
|
||||
{
|
||||
oldValid: local.oldValid,
|
||||
expired: local.expired,
|
||||
fresh: local.fresh,
|
||||
failedActivity: local.failedActivity,
|
||||
credentialUi: local.credentialUi,
|
||||
wizardUi: local.wizardUi,
|
||||
retryUi: local.retryUi,
|
||||
source: local.source,
|
||||
runId: local.runId,
|
||||
reply: local.reply,
|
||||
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,
|
||||
endpoint = await ctx.input.api.get<ChatEndpoint>(
|
||||
`/api/chat-endpoints/${f.endpoint.id}`,
|
||||
),
|
||||
principal = await f.readPrincipal();
|
||||
let sourceBound = false;
|
||||
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: ctx.input.fixtures.agent.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,
|
||||
repair: {
|
||||
endpoint,
|
||||
oldValid: local.oldValid,
|
||||
expired: local.expired,
|
||||
fresh: local.fresh,
|
||||
failedActivity: local.failedActivity,
|
||||
credentialUi: local.credentialUi,
|
||||
wizardUi: local.wizardUi,
|
||||
retryUi: local.retryUi,
|
||||
source: local.source,
|
||||
sourceBound,
|
||||
runId: local.runId,
|
||||
reply: local.reply,
|
||||
messages: local.messages ?? [],
|
||||
connectAt: local.connectAt,
|
||||
principalUserId: principal.paperclipUserId,
|
||||
failedProviderCapture: state.failedSignedProviderDelivery === true,
|
||||
},
|
||||
} satisfies Observation;
|
||||
},
|
||||
async verifyResult(ctx, _state, value) {
|
||||
const o = value as Observation;
|
||||
await verifyRenderedReply(ctx, value);
|
||||
const page = await ctx.input.page.context().newPage();
|
||||
try {
|
||||
await page.goto(
|
||||
`${o.origin}/${ctx.input.fixtures.company.issuePrefix}/apps/chat/${o.repair.endpoint.id}/settings`,
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Connected", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
},
|
||||
async cleanup(ctx) {
|
||||
const local = fixtures.get(ctx);
|
||||
try {
|
||||
await local?.fixture.cleanup();
|
||||
} finally {
|
||||
fixtures.delete(ctx);
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -26,3 +26,26 @@ it("restricts private Slack credential entry to the exact owned existing-app dra
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("admits the exact real Reconnect route only in explicit repair mode", () => {
|
||||
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&reconnect=1`;
|
||||
expect(validSlackSetupPrivate(company, input, url, true)).toBe(true);
|
||||
expect(validSlackSetupPrivate(company, input, url)).toBe(false);
|
||||
expect(
|
||||
validSlackSetupPrivate(
|
||||
company,
|
||||
input,
|
||||
url.replace("reconnect=1", "reconnect=0"),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(validSlackSetupPrivate(company, input, url + "&other=1", true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export function validSlackSetupPrivate(
|
||||
companyId: unknown,
|
||||
input: SlackSetupPrivateInput | undefined,
|
||||
setupURL: string,
|
||||
repair = false,
|
||||
) {
|
||||
try {
|
||||
const url = new URL(setupURL);
|
||||
@@ -27,7 +28,10 @@ export function validSlackSetupPrivate(
|
||||
url.searchParams.get("purpose") === "chat" &&
|
||||
url.searchParams.get("resume") === input.endpointId &&
|
||||
url.searchParams.get("stage") === "credentials" &&
|
||||
url.searchParams.size === 4 &&
|
||||
(repair
|
||||
? url.searchParams.size === 5 &&
|
||||
url.searchParams.get("reconnect") === "1"
|
||||
: url.searchParams.size === 4) &&
|
||||
!url.hash
|
||||
);
|
||||
} catch {
|
||||
|
||||
Reference in new issue
Block a user