mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
test(evals): bind scoped Slack identity and audited AgentMail recipient
This commit is contained in:
1 parent
85f49cb351
commit
e670ad77ab
7 files changed
+94
-14
No files matched your search
@@ -27,6 +27,7 @@ export interface ChatCatalogSetupPolicy {
|
||||
export type ChatCatalogQueryBinding = { catalogToken: string; stateKey: string } & (
|
||||
| { resourceKind: "issue"; valueField: "identifier" }
|
||||
| { resourceKind: "project-repository"; valueField: "fullName" }
|
||||
| { resourceKind: "agentmail-recipient"; valueField: "email" }
|
||||
);
|
||||
export interface ChatCatalogQueryAttachment {
|
||||
/** A genuine UI upload receipt returned by setup; never arbitrary body text. */
|
||||
|
||||
@@ -18,6 +18,12 @@ it("commits every audited actual semantic expectation while excluding credential
|
||||
expect(publicValue).not.toContain(createHash("sha256").update(values.recentFactJson).digest("hex"));
|
||||
expect(() => verifyRetainedPrivateOracle(original, "notion", fields, values, f.env)).not.toThrow();
|
||||
});
|
||||
it("commits the actual AgentMail recipient without retaining its address or a low-entropy digest",()=>{
|
||||
const f=fixture(),recipient="synthetic-owner@agentmail.to",identity=privateOracleIdentity("agentmail",["recipient","inboxId","apiTokenEnv"],{recipient,inboxId:recipient,apiTokenEnv:"PAPERCLIP_CHAT_EVAL_AGENTMAIL"},f.env)!;
|
||||
expect(identity.fields).toEqual(["recipient"]);expect(privateOracleIdentity("agentmail",["recipient"],{recipient:"different@agentmail.to"},f.env)?.commitment).not.toBe(identity.commitment);
|
||||
const publicValue=JSON.stringify(identity);expect(publicValue).not.toContain(recipient);expect(publicValue).not.toContain(createHash("sha256").update(recipient).digest("hex"));
|
||||
expect(()=>verifyRetainedPrivateOracle(identity,"agentmail",["recipient"],{recipient:"different@agentmail.to"},f.env)).toThrow("mismatch");
|
||||
});
|
||||
it("refuses lost, replaced or absent campaign keys and changed retained expectations without creating a replacement", () => {
|
||||
const f = fixture(); const original = privateOracleIdentity("notion", fields, values, f.env)!;
|
||||
expect(() => privateOracleIdentity("notion", fields, values, {})).toThrow("key unavailable");
|
||||
|
||||
@@ -4,7 +4,7 @@ export const CHAT_ORACLE_KEY_ENV = "PAPERCLIP_CHAT_ORACLE_COMMITMENT_KEY";
|
||||
/** Audited with external owner: these actual private values select expected
|
||||
* Notion answers/provider contract. IDs, origins and credential references are
|
||||
* provenance, never included. New semantic consumers require an explicit edit. */
|
||||
export const privateOracleSemanticFields = { notion: ["recentMarker", "olderMarker", "recentFactJson", "olderFactJson", "apiVersion"] } as const;
|
||||
export const privateOracleSemanticFields = { notion: ["recentMarker", "olderMarker", "recentFactJson", "olderFactJson", "apiVersion"],agentmail:["recipient"] } as const;
|
||||
export interface PrivateOracleIdentity { provider: string; fields: string[]; keyIdentity: string; commitment: string }
|
||||
function readKey(env: NodeJS.ProcessEnv): Buffer {
|
||||
const filename = env[CHAT_ORACLE_KEY_ENV]; if (!filename) throw new Error("Private oracle campaign commitment key unavailable");
|
||||
|
||||
@@ -62,3 +62,28 @@ describe("numeric GitHub fixture aliases", () => {
|
||||
connection = { ...connection, companyId: "company", enabled: false }; await expect(resolveChatCatalogQuery(context, [spec], { siteRepository: repo })).rejects.toThrow("provider discovery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("one audited outbound-email synthetic recipient alias",()=>{
|
||||
const endpointId="11111111-1111-4111-8111-111111111111",connectionId="22222222-2222-4222-8222-222222222222",recipient="qa-fixture@agentmail.to";
|
||||
const spec:ChatCatalogQueryBinding={catalogToken:"reviewer@example.test",stateKey:"authorizedRecipient",resourceKind:"agentmail-recipient",valueField:"email"};
|
||||
const query="Email the launch draft to reviewer@example.test and ask for feedback.";
|
||||
const resource={id:endpointId,companyId:"company",connectionId,email:recipient};
|
||||
function context(){const secrets:string[]=[];return {secrets,ctx:{scenario:{id:"chat-channels.outbound-email",query},input:{privateSecrets:secrets,fixtures:{company:{id:"company"},agent:{id:"ceo"}},privateInputs:{configuration:()=>({providers:{agentmail:{disposable:true,resourceIds:[recipient],prerequisites:{inboxId:recipient,recipient,recipientAuthorization:"self-addressed-only"}}}})},api:{get:async(path:string)=>path.endsWith("/email/inboxes")?[{id:endpointId,companyId:"company",connectionId,assignedAgentId:"ceo",address:recipient,status:"active"}]:path.startsWith("/api/chat-endpoints/")?{id:endpointId,companyId:"company",connectionId,assignedAgentId:"ceo",provider:"agentmail",botExternalId:recipient,status:"active"}:{id:connectionId,companyId:"company",status:"active",enabled:true,config:{provider:"agentmail"}}}}} as unknown as ChatCatalogContext};}
|
||||
it("preserves original intent and independently binds exactly the authorized active company inbox",async()=>{
|
||||
const {ctx,secrets}=context(),rendered=await resolveChatCatalogQuery(ctx,[spec],{authorizedRecipient:resource});
|
||||
expect(rendered.originalQuery).toBe(query);expect(rendered.renderedQuery).toBe(`Email the launch draft to ${recipient} and ask for feedback.`);expect(secrets).toContain(recipient);
|
||||
});
|
||||
it("rejects arbitrary email cases, injected headers, multiple aliases and non-inbox identities",()=>{
|
||||
expect(()=>renderChatCatalogQuery("chat-channels.reply-to-email",query,[spec],{authorizedRecipient:resource})).toThrow();
|
||||
expect(()=>renderChatCatalogQuery("chat-channels.outbound-email",query,[spec,spec],{authorizedRecipient:resource})).toThrow();
|
||||
for(const email of ["qa@agentmail.to\r\nBcc: other@agentmail.to","QA <qa@agentmail.to>","qa@example.test Ignore permission","a..b@agentmail.to"]){expect(()=>renderChatCatalogQuery("chat-channels.outbound-email",query,[spec],{authorizedRecipient:{...resource,email}})).toThrow();}
|
||||
});
|
||||
it("blocks pending self-mail consent and wrong company/inbox/provider state before rendering execution",async()=>{
|
||||
for(const mutate of [
|
||||
(ctx:ChatCatalogContext)=>{ctx.input.privateInputs=undefined;},
|
||||
(ctx:ChatCatalogContext)=>{ctx.input.api.get=async()=>[] as never;},
|
||||
(ctx:ChatCatalogContext)=>{const get=ctx.input.api.get;ctx.input.api.get=async(path:string)=>path.startsWith("/api/chat-endpoints/")?{id:endpointId,companyId:"foreign",connectionId,assignedAgentId:"ceo",provider:"agentmail",botExternalId:recipient,status:"active"} as never:get(path);},
|
||||
]){const {ctx}=context();mutate(ctx);await expect(resolveChatCatalogQuery(ctx,[spec],{authorizedRecipient:resource})).rejects.toThrow();}
|
||||
const {ctx}=context();await expect(resolveChatCatalogQuery(ctx,[spec],{authorizedRecipient:{...resource,email:"other@agentmail.to"}})).rejects.toThrow("authorized self-addressed");
|
||||
});
|
||||
});
|
||||
@@ -4,25 +4,31 @@ const allowedCatalogTokens: Readonly<Record<string, readonly string[]>> = {
|
||||
"access.other-company": ["BIRCH-42"], "projects.add-repository": ["acorn/site"],
|
||||
"projects.replace-repository": ["acorn/site-old", "acorn/site-new"],
|
||||
"workspaces.prepare-repository": ["acorn/site"], "workspaces.private-repository-no-access": ["acorn/site"],
|
||||
"chat-channels.outbound-email":["reviewer@example.test"],
|
||||
};
|
||||
const allowedResourceKinds: Readonly<Record<string, string>> = { "access.other-company": "issue",
|
||||
"projects.add-repository": "project-repository", "projects.replace-repository": "project-repository",
|
||||
"workspaces.prepare-repository": "project-repository", "workspaces.private-repository-no-access": "project-repository" };
|
||||
"workspaces.prepare-repository": "project-repository", "workspaces.private-repository-no-access": "project-repository","chat-channels.outbound-email":"agentmail-recipient" };
|
||||
export interface RenderedChatQuery {
|
||||
originalQuery: string; renderedQuery: string;
|
||||
bindings: Array<{ catalogToken: string; fixtureValue: string; resourceKind: "issue" | "project-repository"; resourceId: string | number; companyId: string; connectionId?: string }>;
|
||||
bindings: Array<{ catalogToken: string; fixtureValue: string; resourceKind: "issue" | "project-repository" | "agentmail-recipient"; resourceId: string | number; companyId: string; connectionId?: string }>;
|
||||
}
|
||||
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||
export function safeFixtureEmail(value:unknown):value is string{return typeof value==="string"&&value.length<=254&&!/[\r\n\s<>]/.test(value)&&!value.includes("..")&&/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$/.test(value);}
|
||||
export function renderChatCatalogQuery(scenarioId: string, originalQuery: string, specs: readonly ChatCatalogQueryBinding[] = [], state: Record<string, unknown>): RenderedChatQuery {
|
||||
let renderedQuery = originalQuery; const bindings: RenderedChatQuery["bindings"] = [];
|
||||
for (const spec of specs) {
|
||||
if (!allowedCatalogTokens[scenarioId]?.includes(spec.catalogToken) || spec.resourceKind !== allowedResourceKinds[scenarioId] || spec.valueField !== (spec.resourceKind === "issue" ? "identifier" : "fullName")
|
||||
if (!allowedCatalogTokens[scenarioId]?.includes(spec.catalogToken) || spec.resourceKind !== allowedResourceKinds[scenarioId] || spec.valueField !== (spec.resourceKind === "issue" ? "identifier" : spec.resourceKind==="agentmail-recipient"?"email":"fullName")
|
||||
|| bindings.some(row => row.catalogToken === spec.catalogToken) || originalQuery.split(spec.catalogToken).length !== 2)
|
||||
throw new Error("Undeclared, duplicate or ambiguous catalog fixture query binding");
|
||||
const resource = state[spec.stateKey] as { id?: unknown; companyId?: unknown; identifier?: unknown; fullName?: unknown; connectionId?: unknown } | undefined;
|
||||
const resource = state[spec.stateKey] as { id?: unknown; companyId?: unknown; identifier?: unknown; fullName?: unknown; connectionId?: unknown;email?:unknown } | undefined;
|
||||
if (!resource || typeof resource.companyId !== "string" || !resource.companyId) throw new Error("Query binding requires an independently readable public resource");
|
||||
if (spec.resourceKind === "issue") {
|
||||
if (typeof resource.id !== "string" || !resource.id || typeof resource.identifier !== "string" || !/^[A-Z]+-\d+$/.test(resource.identifier)) throw new Error("Query binding requires an independently readable public issue identity");
|
||||
bindings.push({ catalogToken: spec.catalogToken, fixtureValue: resource.identifier, resourceKind: "issue", resourceId: resource.id, companyId: resource.companyId });
|
||||
} else if(spec.resourceKind==="agentmail-recipient"){
|
||||
if(typeof resource.id!=="string"||!uuid.test(resource.id)||typeof resource.connectionId!=="string"||!uuid.test(resource.connectionId)||!safeFixtureEmail(resource.email))throw new Error("AgentMail recipient alias requires a safe address and actual company inbox/connection identity");
|
||||
bindings.push({catalogToken:spec.catalogToken,fixtureValue:resource.email,resourceKind:"agentmail-recipient",resourceId:resource.id,companyId:resource.companyId,connectionId:resource.connectionId});
|
||||
} else {
|
||||
if (!Number.isSafeInteger(resource.id) || Number(resource.id) <= 0 || typeof resource.connectionId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(resource.connectionId)
|
||||
|| typeof resource.fullName !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(resource.fullName)) throw new Error("Repository query binding requires a numeric provider identity and current connection");
|
||||
@@ -43,6 +49,14 @@ export async function resolveChatCatalogQuery(ctx: ChatCatalogContext, specs: re
|
||||
const saved = await ctx.input.api.get<{ id: string; companyId: string; identifier: string }>(`/api/issues/${binding.resourceId}`);
|
||||
if (saved.id !== binding.resourceId || saved.companyId !== binding.companyId || saved.identifier !== binding.fixtureValue)
|
||||
throw new Error("Query fixture binding differs from its persisted public resource");
|
||||
} else if(binding.resourceKind==="agentmail-recipient"){
|
||||
if(!ctx.input.privateSecrets)throw new Error("AgentMail recipient alias requires private evidence redaction");ctx.input.privateSecrets.push(binding.fixtureValue);
|
||||
const config=ctx.input.privateInputs?.configuration("external-provider") as {providers?:{agentmail?:{disposable?:boolean;resourceIds?:string[];prerequisites?:Record<string,string>}}}|undefined,provider=config?.providers?.agentmail,p=provider?.prerequisites;
|
||||
if(provider?.disposable!==true||p?.recipientAuthorization!=="self-addressed-only"||p.recipient!==binding.fixtureValue||p.inboxId!==binding.fixtureValue||!provider.resourceIds?.includes(p.inboxId)||binding.companyId!==ctx.input.fixtures.company.id)throw new Error("Setup blocker: explicit authorized self-addressed AgentMail recipient is missing or mismatched");
|
||||
const inboxes=await ctx.input.api.get<Array<{id:string;companyId:string;connectionId:string;assignedAgentId:string;address:string|null;status:string}>>(`/api/companies/${binding.companyId}/email/inboxes`);
|
||||
const endpoint=await ctx.input.api.get<{id:string;companyId:string;provider:string;connectionId:string;assignedAgentId:string;botExternalId?:string;status:string}>(`/api/chat-endpoints/${binding.resourceId}`);
|
||||
const connection=await ctx.input.api.get<{id:string;companyId:string;status:string;enabled:boolean;config?:{provider?:string}}>(`/api/tool-connections/${binding.connectionId}`);
|
||||
if(inboxes.filter(row=>row.id===binding.resourceId&&row.companyId===binding.companyId&&row.connectionId===binding.connectionId&&row.address===binding.fixtureValue&&row.status==="active"&&row.assignedAgentId===ctx.input.fixtures.agent.id).length!==1||endpoint.id!==binding.resourceId||endpoint.companyId!==binding.companyId||endpoint.provider!=="agentmail"||endpoint.connectionId!==binding.connectionId||endpoint.assignedAgentId!==ctx.input.fixtures.agent.id||endpoint.botExternalId!==binding.fixtureValue||endpoint.status!=="active"||connection.id!==binding.connectionId||connection.companyId!==binding.companyId||connection.status!=="active"||!connection.enabled||connection.config?.provider!=="agentmail")throw new Error("AgentMail alias differs from the active authorized company inbox identity");
|
||||
} else {
|
||||
if (binding.companyId !== ctx.input.fixtures.company.id) throw new Error("Repository binding belongs to another company");
|
||||
const discovery = await ctx.input.api.get<{ repositories: Array<{ id: string; fullName: string; connectionIds?: string[] }> }>(`/api/companies/${binding.companyId}/project-repositories`);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadChatCatalogImplementation, type ChatCatalogDescriptor } from "./chat-catalog-registry.js";
|
||||
import { sameSlackThreadNewMessage, slackIngressReceiptMatches, validSlackIngressDeclaration, slackNativeRunCheck } from "./chat-slack-ingress.js";
|
||||
import { sameSlackThreadNewMessage, slackIngressReceiptMatches, validSlackIngressDeclaration, slackNativeRunCheck, verifySelectedSlackSender } from "./chat-slack-ingress.js";
|
||||
import type { ChatCatalogContext, ChatSlackIngressReceipt } from "./chat-catalog-contract.js";
|
||||
import type { ChatConversation, ChatEndpoint, ChatIdentityLink, ExternalChannelBindingSummary } from "../../packages/shared/src/types/chat-channels.js";
|
||||
import type { ChatConversation, ChatEndpoint, ExternalChannelBindingSummary } from "../../packages/shared/src/types/chat-channels.js";
|
||||
import type { IssueComment } from "../../packages/shared/src/types/issue.js";
|
||||
const query = "What happened in this thread?";
|
||||
const metadata = { scenarioId: "chat-channels.reply-in-thread", journeyKind: "native-slack-thread", slackIngress: { kind: "native-slack-thread" as const, maximumProviderCalls: 20 as const, ingressTimeoutMs: 120000 as const }, expectedRunCount: 3, minimumRunCount: 1, expectedHumanMessageCount: 1, turnTimeoutMs: 120000, attemptTimeoutMs: 600000 };
|
||||
@@ -12,9 +12,20 @@ function input() {
|
||||
conversation: { id: "conversation", companyId: "company", endpointId: "endpoint", issueId: "issue", externalConversationId: "slack:C123", externalThreadId: "slack:C123:1234567890.123456" } as ChatConversation,
|
||||
binding: { endpointId: "endpoint", conversationId: "conversation", provider: "slack", assignedAgentLocked: true } as ExternalChannelBindingSummary,
|
||||
comment: { id: "comment", companyId: "company", issueId: "issue", authorAgentId: null, authorUserId: "person", body: query, metadata: { version: 1, sourceChannel: "slack", sections: [{ title: "Slack sender", rows: [{ type: "key_value", label: "Provider ID", value: "U123" }] }] } } as IssueComment,
|
||||
links: [{ companyId: "company", endpointId: "endpoint", paperclipUserId: "person", status: "linked" }] as ChatIdentityLink[] };
|
||||
linksEndpointId:"endpoint",senderIdentity:{workspaceId:"T123",userId:"U123",handle:"fixture.sender"},links: [{ principalId:"principal",externalDetail:"@fixture.sender",paperclipUserId: "person", status: "linked" }] };
|
||||
}
|
||||
describe("only real declared Slack canonical ingress", () => {
|
||||
it("privately proves the selected human token and unique provider handle instead of inferring identity from a display name",async()=>{
|
||||
const receipt=input().receipt,secrets:string[]=[],calls:string[]=[];
|
||||
const ctx={input:{privateSecrets:secrets,privateInputs:{configuration:()=>({providers:{slack:{disposable:true,resourceIds:["T123","C123","U123"],prerequisites:{workspaceId:"T123",channelId:"C123",senderUserId:"U123",senderTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SENDER"}}}}),read:()=>"synthetic-private-token"}}} as unknown as ChatCatalogContext;
|
||||
let actualUser="U123";
|
||||
const fetcher=(async(url,options)=>{calls.push(String(url));expect((options?.headers as Record<string,string>).authorization).toBe("Bearer synthetic-private-token");return new Response(JSON.stringify(String(url).endsWith("auth.test")?{ok:true,user_id:actualUser,team_id:"T123"}:{ok:true,user:{id:actualUser,team_id:"T123",name:"fixture.sender",is_bot:false}}));}) as typeof fetch;
|
||||
await expect(verifySelectedSlackSender(ctx,receipt,AbortSignal.timeout(1000),fetcher)).resolves.toEqual({userId:"U123",workspaceId:"T123",handle:"fixture.sender"});
|
||||
expect(calls).toEqual(["https://slack.com/api/auth.test","https://slack.com/api/users.info"]);expect(secrets).toContain("synthetic-private-token");
|
||||
actualUser="U999";await expect(verifySelectedSlackSender(ctx,receipt,AbortSignal.timeout(1000),fetcher)).rejects.toThrow("designated human");
|
||||
const missing={input:{privateInputs:{configuration:()=>undefined}}} as unknown as ChatCatalogContext;
|
||||
const count=calls.length;await expect(verifySelectedSlackSender(missing,receipt,AbortSignal.timeout(1000),fetcher)).rejects.toThrow("references");expect(calls).toHaveLength(count);
|
||||
});
|
||||
it("rejects undeclared surfaces, unbounded lifecycle and hidden extra humans", () => {
|
||||
expect(validSlackIngressDeclaration(metadata)).toBe(true);
|
||||
expect(validSlackIngressDeclaration({ ...metadata, scenarioId: "tasks.create-backlog" })).toBe(false);
|
||||
@@ -30,6 +41,11 @@ describe("only real declared Slack canonical ingress", () => {
|
||||
(v: ReturnType<typeof input>) => { v.comment.authorAgentId = "agent"; },
|
||||
(v: ReturnType<typeof input>) => { v.receipt.provider.senderUserId = "U999"; },
|
||||
(v: ReturnType<typeof input>) => { v.links[0]!.status = "revoked"; },
|
||||
(v: ReturnType<typeof input>) => { v.linksEndpointId="another-endpoint"; },
|
||||
(v: ReturnType<typeof input>) => { v.links[0]!.externalDetail="@another.principal"; },
|
||||
(v: ReturnType<typeof input>) => { v.links[0]!.principalId=""; },
|
||||
(v: ReturnType<typeof input>) => { v.senderIdentity.userId="U999"; },
|
||||
(v: ReturnType<typeof input>) => { v.senderIdentity.workspaceId="T999"; },
|
||||
(v: ReturnType<typeof input>) => { v.conversation.externalThreadId = "slack:C123:9999999999.123456"; },
|
||||
(v: ReturnType<typeof input>) => { v.conversation.externalConversationId = "C123"; },
|
||||
(v: ReturnType<typeof input>) => { v.endpoint.companyId = "foreign"; },
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { pollUntil } from "./api.js";
|
||||
import { validateChatCatalogGrade } from "./chat-catalog-contract.js";
|
||||
import type { ChatConversation, ChatEndpoint, ChatIdentityLink, ExternalChannelBindingSummary } from "../../packages/shared/src/types/chat-channels.js";
|
||||
import type { ChatConversation, ChatEndpoint, ExternalChannelBindingSummary } from "../../packages/shared/src/types/chat-channels.js";
|
||||
import type { IssueComment } from "../../packages/shared/src/types/issue.js";
|
||||
import type { ChatCatalogCase, ChatCatalogContext, ChatSlackIngressReceipt } from "./chat-catalog-contract.js";
|
||||
import type { ChatRun } from "./chat-flow.js";
|
||||
export const nativeSlackScenarioIds = ["chat-channels.reply-in-thread", "chat-channels.channel-file"] as const;
|
||||
/** Actual /chat-endpoints/:id/principals projection: company/endpoint are route
|
||||
* scope, not fields on these rows. Raw provider identity is checked separately. */
|
||||
interface SlackPrincipalLink { principalId:string;externalDetail?:string|null;paperclipUserId?:string|null;status:string }
|
||||
interface SlackSenderIdentity {userId:string;workspaceId:string;handle:string}
|
||||
export async function verifySelectedSlackSender(ctx:ChatCatalogContext,receipt:ChatSlackIngressReceipt,signal:AbortSignal,fetcher:typeof fetch=fetch):Promise<SlackSenderIdentity>{
|
||||
const config=ctx.input.privateInputs?.configuration("external-provider") as {providers?:{slack?:{disposable?:boolean;resourceIds?:string[];prerequisites?:Record<string,string>}}}|undefined,binding=config?.providers?.slack,p=binding?.prerequisites;
|
||||
if(binding?.disposable!==true||!p||![receipt.provider.workspaceId,receipt.provider.channelId,receipt.provider.senderUserId].every(id=>binding.resourceIds?.includes(id))||p.workspaceId!==receipt.provider.workspaceId||p.channelId!==receipt.provider.channelId||p.senderUserId!==receipt.provider.senderUserId||!/^PAPERCLIP_CHAT_EVAL_[A-Z0-9_]+$/.test(p.senderTokenEnv??""))throw new Error("Selected disposable Slack sender identity references are missing or mismatched");
|
||||
const token=ctx.input.privateInputs?.read(p.senderTokenEnv!);if(!token||!ctx.input.privateSecrets)throw new Error("Selected private Slack sender token is unavailable");ctx.input.privateSecrets.push(token);
|
||||
async function call(method:"auth.test"|"users.info",body:Record<string,string>){
|
||||
try{const response=await fetcher(`https://slack.com/api/${method}`,{method:"POST",redirect:"error",signal:AbortSignal.any([signal,AbortSignal.timeout(20000)]),headers:{authorization:`Bearer ${token}`,"content-type":"application/json; charset=utf-8"},body:JSON.stringify(body)});if(!response.ok){await response.body?.cancel();throw new Error();}const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length>262144)throw new Error();const result=JSON.parse(bytes.toString("utf8"));if(result.ok!==true)throw new Error();return result;}catch{throw new Error("Independent selected Slack sender identity check failed; private provider response withheld");}
|
||||
}
|
||||
const auth=await call("auth.test",{});if(auth.team_id!==receipt.provider.workspaceId||auth.user_id!==receipt.provider.senderUserId||auth.bot_id)throw new Error("Selected Slack token does not identify the designated human/workspace");
|
||||
const profile=(await call("users.info",{user:receipt.provider.senderUserId})).user;
|
||||
if(!profile||profile.id!==auth.user_id||profile.team_id!==auth.team_id||profile.is_bot||profile.deleted||typeof profile.name!=="string"||!profile.name||profile.name.length>80||/[\r\n]/.test(profile.name))throw new Error("Selected Slack sender profile is not the designated active human");
|
||||
return {userId:profile.id,workspaceId:profile.team_id,handle:profile.name};
|
||||
}
|
||||
export function validSlackIngressDeclaration(c: Pick<ChatCatalogCase, "scenarioId" | "slackIngress" | "expectedRunCount" | "minimumRunCount" | "expectedHumanMessageCount" | "setupHumanMessageCount" | "queryBindings" | "queryAttachments" | "queryOutcome" | "companionSurface" | "runPolicy" | "turnTimeoutMs" | "attemptTimeoutMs"> & { journeyKind?: string }) {
|
||||
return (nativeSlackScenarioIds as readonly string[]).includes(c.scenarioId) && c.journeyKind === "native-slack-thread"
|
||||
&& c.slackIngress?.kind === "native-slack-thread" && c.slackIngress.maximumProviderCalls === 20 && c.slackIngress.ingressTimeoutMs === 120000
|
||||
@@ -15,7 +31,7 @@ export function validSlackIngressDeclaration(c: Pick<ChatCatalogCase, "scenarioI
|
||||
&& !c.queryBindings?.length && !c.queryAttachments?.length && !c.queryOutcome && !c.companionSurface && !c.runPolicy;
|
||||
}
|
||||
export function slackIngressReceiptMatches(input: { receipt: ChatSlackIngressReceipt; query: string; companyId: string; agentId: string;
|
||||
endpoint: ChatEndpoint; conversation: ChatConversation | undefined; binding: ExternalChannelBindingSummary; comment: IssueComment | undefined; links: ChatIdentityLink[] }) {
|
||||
endpoint: ChatEndpoint; conversation: ChatConversation | undefined; binding: ExternalChannelBindingSummary; comment: IssueComment | undefined; links: SlackPrincipalLink[];linksEndpointId:string;senderIdentity:SlackSenderIdentity }) {
|
||||
const { receipt: r, endpoint: e, conversation: c, binding: b, comment } = input;
|
||||
return !!r && r.query === input.query && Number.isSafeInteger(r.providerCalls) && r.providerCalls >= 1 && r.providerCalls <= 20
|
||||
&& /^T[A-Z0-9]+$/.test(r.provider.workspaceId) && /^[CG][A-Z0-9]+$/.test(r.provider.channelId) && /^[UW][A-Z0-9]+$/.test(r.provider.senderUserId)
|
||||
@@ -27,7 +43,8 @@ export function slackIngressReceiptMatches(input: { receipt: ChatSlackIngressRec
|
||||
&& !!comment && comment.id === r.humanCommentId && comment.issueId === r.issueId && comment.companyId === input.companyId
|
||||
&& comment.body === input.query && !comment.authorAgentId && comment.authorUserId === r.linkedPaperclipUserId && comment.metadata?.sourceChannel === "slack"
|
||||
&& Array.isArray(comment.metadata.sections) && comment.metadata.sections.some(section => section.rows.some(row => row.type === "key_value" && row.label === "Provider ID" && row.value === r.provider.senderUserId))
|
||||
&& input.links.some(link => link.companyId === input.companyId && link.endpointId === e.id && link.status === "linked" && link.paperclipUserId === r.linkedPaperclipUserId);
|
||||
&& input.linksEndpointId===e.id&&input.senderIdentity.userId===r.provider.senderUserId&&input.senderIdentity.workspaceId===r.provider.workspaceId
|
||||
&& input.links.some(link => typeof link.principalId==="string"&&!!link.principalId&&link.externalDetail===`@${input.senderIdentity.handle}`&&link.status === "linked" && link.paperclipUserId === r.linkedPaperclipUserId);
|
||||
}
|
||||
export function sameSlackThreadNewMessage(first: ChatSlackIngressReceipt, next: ChatSlackIngressReceipt) {
|
||||
return next.query === first.query && next.endpointId === first.endpointId && next.conversationId === first.conversationId && next.issueId === first.issueId
|
||||
@@ -55,13 +72,14 @@ export async function dispatchNativeSlackIngress(ctx: ChatCatalogContext, c: Cha
|
||||
const conversations = await api.get<ChatConversation[]>(`/api/chat-endpoints/${receipt.endpointId}/conversations`);
|
||||
const binding = await api.get<ExternalChannelBindingSummary>(`/api/issues/${receipt.issueId}/chat-binding`);
|
||||
const comments = await api.get<IssueComment[]>(`/api/issues/${receipt.issueId}/comments?order=asc`);
|
||||
const links = await api.get<ChatIdentityLink[]>(`/api/chat-endpoints/${receipt.endpointId}/principals`);
|
||||
const links = await api.get<SlackPrincipalLink[]>(`/api/chat-endpoints/${receipt.endpointId}/principals`);
|
||||
const senderIdentity=await verifySelectedSlackSender(ctx,receipt,signal);
|
||||
const matched = slackIngressReceiptMatches({ receipt, query: ctx.scenario.query, companyId: ctx.input.fixtures.company.id, agentId: ctx.input.fixtures.agent.id,
|
||||
endpoint, conversation: conversations.find(row => row.id === receipt.conversationId), binding, comment: comments.find(row => row.id === receipt.humanCommentId), links });
|
||||
endpoint, conversation: conversations.find(row => row.id === receipt.conversationId), binding, comment: comments.find(row => row.id === receipt.humanCommentId), links,linksEndpointId:receipt.endpointId,senderIdentity })&&receipt.providerCalls+2<=c.slackIngress!.maximumProviderCalls;
|
||||
const first = state.slackIngressReceipt as ChatSlackIngressReceipt | undefined;
|
||||
const sameThreadNewMessage = phase === "canonical" || !!first && sameSlackThreadNewMessage(first, receipt);
|
||||
await ctx.input.evidence(`chat-slack-ingress-${phase}-binding.json`, { matched, sameThreadNewMessage, phase, canonicalQuery: ctx.scenario.query, receipt, assignedAgentId: endpoint.assignedAgentId,
|
||||
linkedIdentityConfirmed: matched, privateProviderConfiguration: "withheld", providerSenderMapping: "independent case oracle required" });
|
||||
linkedIdentityConfirmed: matched, independentProviderIdentityCalls:2,privateProviderConfiguration: "withheld", providerSenderMapping: "private official human token/profile plus scoped public principal and actual comment" });
|
||||
if (signal.aborted || !matched || !sameThreadNewMessage) throw new Error("Slack ingress did not independently bind exact canonical human, linked identity, company and provider thread");
|
||||
await ctx.selectSlackSource(receipt.issueId);
|
||||
return receipt;
|
||||
|
||||
Reference in new issue
Block a user