mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 12:29:37 +01:00
test(evals): author actual Slack file transfer and size phases
This commit is contained in:
1 parent
9aa09b51c7
commit
522c5f1bec
5 files changed
+1720
-104
No files matched your search
@@ -30,7 +30,7 @@ export const externalRequirements: Record<string, { provider: ExternalProvider;
|
||||
"chat-channels.connect-slack": { provider: "slack", fields: ["workspaceId", "channelId", "senderUserId", "registeredWebhookOrigin"] },
|
||||
"chat-channels.link-sender": { provider: "slack", fields: ["workspaceId", "privateChannelId", "senderUserId", "registeredWebhookOrigin"] },
|
||||
"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", "originalThreadId", "briefFileId", "senderUserId", "registeredWebhookOrigin"] },
|
||||
"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.outbound-email": { provider: "agentmail", fields: ["inboxId", "recipient", "draftArtifactId"] },
|
||||
"chat-channels.reply-to-email": { provider: "agentmail", fields: ["inboxId", "threadId", "latestMessageId", "earlierMessageId", "recipient"] },
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_ATTACHMENT_BYTES } from "../../../server/src/attachment-types.js";
|
||||
import {
|
||||
gradeSlackFile,
|
||||
oversizedBriefAnswer,
|
||||
observedSlackBriefRead,
|
||||
} from "./external-slack-file.js";
|
||||
const hash = (body: string) => createHash("sha256").update(body).digest("hex");
|
||||
function valid() {
|
||||
const query = "Use the attached brief and send the finished file back here.";
|
||||
const brief =
|
||||
"# Acorn Notes launch memo\nProduce a finished Markdown memo for Acorn Notes. Founding beta invitations open on 14 February 2031. The first 500 members receive research migration support. Include both facts in the finished file and attach that file back to this discussion.\n";
|
||||
const body =
|
||||
"# Acorn Notes\nFounding beta invitations open February 14, 2031. The first 500 members receive research migration support.\n";
|
||||
const source = {
|
||||
query,
|
||||
endpointId: "endpoint",
|
||||
conversationId: "conversation",
|
||||
issueId: "source",
|
||||
humanCommentId: "human1",
|
||||
linkedPaperclipUserId: "person",
|
||||
providerCalls: 6,
|
||||
provider: {
|
||||
workspaceId: "T123",
|
||||
channelId: "C123",
|
||||
threadId: "1234567890.000001",
|
||||
canonicalMessageId: "1234567890.000002",
|
||||
senderUserId: "U123",
|
||||
},
|
||||
};
|
||||
const second = {
|
||||
...source,
|
||||
humanCommentId: "human2",
|
||||
provider: { ...source.provider, canonicalMessageId: "1234567890.000009" },
|
||||
};
|
||||
const permalink =
|
||||
"https://owned.slack.com/files/U123/FOVERSIZE/oversized-brief.pdf";
|
||||
const input = {
|
||||
id: "input",
|
||||
issueId: "source",
|
||||
issueCommentId: "human1",
|
||||
createdByUserId: "person",
|
||||
sha256: hash(brief),
|
||||
byteSize: Buffer.byteLength(brief),
|
||||
};
|
||||
const output = {
|
||||
id: "output",
|
||||
issueId: "source",
|
||||
createdByAgentId: "ceo",
|
||||
originatingRunId: "first",
|
||||
sha256: hash(body),
|
||||
byteSize: Buffer.byteLength(body),
|
||||
};
|
||||
const run = (id: string) => ({
|
||||
id,
|
||||
companyId: "company",
|
||||
agentId: "ceo",
|
||||
runtimeMode: "native",
|
||||
status: "succeeded",
|
||||
contextSnapshot: {
|
||||
issueId: "source",
|
||||
externalAttachmentOmissions:
|
||||
id === "second"
|
||||
? [{ commentId: "human2", reasons: { declared_too_large: 1 } }]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
return {
|
||||
scenarioId: "chat-channels.channel-file",
|
||||
companyId: "company",
|
||||
leadId: "ceo",
|
||||
origin: "http://127.0.0.1:3399",
|
||||
reply: "",
|
||||
state: { originalQuery: query },
|
||||
phaseReplies: [
|
||||
{
|
||||
runId: "second",
|
||||
body: `The attached PDF exceeds the size limit, so I cannot read it or produce a new result. You can download that brief from ${permalink}`,
|
||||
},
|
||||
],
|
||||
tasks: [],
|
||||
runs: [run("first"), run("second")],
|
||||
connections: [],
|
||||
profiles: [],
|
||||
secrets: [],
|
||||
endpoints: [],
|
||||
interactions: [],
|
||||
activity: [],
|
||||
links: [],
|
||||
humanQueries: [query, query],
|
||||
transfer: {
|
||||
source,
|
||||
input: {
|
||||
id: "FBRIEF",
|
||||
hash: hash(brief),
|
||||
byteSize: Buffer.byteLength(brief),
|
||||
},
|
||||
storedInput: input,
|
||||
actualBriefRead: true,
|
||||
attachments: [input, output],
|
||||
products: [
|
||||
{
|
||||
type: "artifact",
|
||||
issueId: "source",
|
||||
createdByRunId: "first",
|
||||
metadata: { attachmentId: "output" },
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
id: "output",
|
||||
hash: hash(body),
|
||||
byteSize: Buffer.byteLength(body),
|
||||
body,
|
||||
},
|
||||
],
|
||||
delivered: [
|
||||
{ id: "FRESULT", hash: hash(body), byteSize: Buffer.byteLength(body) },
|
||||
],
|
||||
firstRunIds: ["first"],
|
||||
second,
|
||||
oversized: {
|
||||
id: "FOVERSIZE",
|
||||
hash: "actual-provider-byte-digest",
|
||||
byteSize: MAX_ATTACHMENT_BYTES + 1,
|
||||
permalink,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
describe("actual Slack supported/oversized file phases", () => {
|
||||
it("accepts independently matching input/output/provider bytes and a truthful exact-file alternative", () =>
|
||||
expect(gradeSlackFile(valid()).passed).toBe(true));
|
||||
it("rejects title-only transfer, wrong bytes/author/run/fact, missing size admission and prior-file substitution", () => {
|
||||
const changes: Array<(value: ReturnType<typeof valid>) => void> = [
|
||||
(o) => {
|
||||
o.transfer.actualBriefRead = false;
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.storedInput.createdByUserId = "other";
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.storedInput.issueCommentId = "another-comment";
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.storedInput.sha256 = "wrong";
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.delivered = [];
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.delivered[0]!.hash = "wrong";
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.outputs[0]!.body = "Acorn Notes filename only";
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.products[0]!.createdByRunId = "previous";
|
||||
},
|
||||
(o) => {
|
||||
o.runs[1]!.contextSnapshot.externalAttachmentOmissions = [];
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.oversized.byteSize = MAX_ATTACHMENT_BYTES;
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.second.provider.threadId = "1234567890.000099";
|
||||
},
|
||||
(o) => {
|
||||
o.phaseReplies[0]!.runId = "first";
|
||||
},
|
||||
(o) => {
|
||||
o.phaseReplies[0]!.body =
|
||||
"The brief is too large. Download the old result at https://owned.slack.com/files/UBOT/FOLD/result.md";
|
||||
},
|
||||
(o) => {
|
||||
o.phaseReplies[0]!.body +=
|
||||
" I processed the oversized PDF and the new memo is ready.";
|
||||
},
|
||||
(o) => {
|
||||
o.transfer.attachments.push({
|
||||
...o.transfer.attachments[1]!,
|
||||
id: "new-output",
|
||||
originatingRunId: "second",
|
||||
});
|
||||
},
|
||||
];
|
||||
for (const change of changes) {
|
||||
const o = valid();
|
||||
change(o);
|
||||
expect(gradeSlackFile(o).passed).toBe(false);
|
||||
}
|
||||
expect(gradeSlackFile(undefined).passed).toBe(false);
|
||||
});
|
||||
it("accepts natural no-read wording but rejects contradictory new-result assertions", () => {
|
||||
const url = valid().transfer.oversized.permalink;
|
||||
expect(
|
||||
oversizedBriefAnswer(
|
||||
`That upload is too large; I could not read the new PDF. Download the same original here: ${url}`,
|
||||
url,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
oversizedBriefAnswer(
|
||||
`Size limit exceeded; I used the new attached PDF and the new result is ready. ${url}`,
|
||||
url,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("never accepts ordinary transcript assertions as a completed native byte read", () => {
|
||||
const event = {
|
||||
eventType: "item.completed",
|
||||
protocolSchemaVersion: 2,
|
||||
payload: {
|
||||
prpEvent: {
|
||||
schema: "paperclip.prp.event.v2",
|
||||
schemaVersion: 2,
|
||||
sourceKind: "runner",
|
||||
payload: {
|
||||
item: {
|
||||
type: "tool_result",
|
||||
result:
|
||||
"Produce a finished Markdown memo for Acorn Notes. Founding beta invitations open on 14 February 2031. The first 500 members receive research migration support.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(observedSlackBriefRead([event])).toBe(true);
|
||||
expect(
|
||||
observedSlackBriefRead([{ ...event, eventType: "item.started" }]),
|
||||
).toBe(false);
|
||||
expect(
|
||||
observedSlackBriefRead([{ ...event, protocolSchemaVersion: 1 }]),
|
||||
).toBe(false);
|
||||
expect(observedSlackBriefRead([])).toBe(false);
|
||||
expect(
|
||||
observedSlackBriefRead([
|
||||
{
|
||||
eventType: "item.completed",
|
||||
payload: {
|
||||
item: {
|
||||
type: "tool_result",
|
||||
result: valid().transfer.outputs[0]!.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,580 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { expect } from "@playwright/test";
|
||||
import type {
|
||||
ChatCatalogContext,
|
||||
ChatSlackIngressReceipt,
|
||||
} from "../chat-catalog-contract.js";
|
||||
import type { IssueAttachment } from "../../../packages/shared/src/types/issue.js";
|
||||
import type { IssueWorkProduct } from "../../../packages/shared/src/types/work-product.js";
|
||||
import { MAX_ATTACHMENT_BYTES } from "../../../server/src/attachment-types.js";
|
||||
import { collectRunEvents } from "../run-observations.js";
|
||||
import { isValidNativePrpEnvelope } from "../native-event-envelope.js";
|
||||
import { pollUntil } from "../api.js";
|
||||
import { privateScanJson } from "./external-secret-scan.js";
|
||||
import {
|
||||
prepareSlackFixture,
|
||||
slackCompanionFields,
|
||||
} from "./external-slack-fixture.js";
|
||||
import {
|
||||
definition,
|
||||
checks,
|
||||
observeExternal,
|
||||
externalObservation,
|
||||
verifyRenderedReply,
|
||||
type ExternalObservation,
|
||||
} from "./external-support.js";
|
||||
|
||||
const digest = (bytes: Buffer | string) =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
const brief =
|
||||
"# Acorn Notes launch memo\nProduce a finished Markdown memo for Acorn Notes. Founding beta invitations open on 14 February 2031. The first 500 members receive research migration support. Include both facts in the finished file and attach that file back to this discussion.\n";
|
||||
const row = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
export function hasLaunchMemoFacts(body: string) {
|
||||
return (
|
||||
/Acorn\s+Notes/i.test(body) &&
|
||||
/(?:14\s+February\s+2031|February\s+14,?\s+2031|2031-02-14)/i.test(body) &&
|
||||
/500\b/.test(body) &&
|
||||
/research\s+migration\s+support/i.test(body)
|
||||
);
|
||||
}
|
||||
/** Only an actual completed native tool result can prove the input bytes were read. */
|
||||
export function observedSlackBriefRead(events: Record<string, unknown>[]) {
|
||||
return events.some((event) => {
|
||||
const envelope = row(row(event.payload).prpEvent);
|
||||
if (
|
||||
!isValidNativePrpEnvelope(envelope, event.protocolSchemaVersion) ||
|
||||
envelope.sourceKind !== "runner" ||
|
||||
event.eventType !== "item.completed"
|
||||
)
|
||||
return false;
|
||||
const item = row(row(envelope.payload).item);
|
||||
const result = JSON.stringify(item.result);
|
||||
return (
|
||||
item.type === "tool_result" &&
|
||||
result.includes("Produce a finished Markdown memo for Acorn Notes.") &&
|
||||
hasLaunchMemoFacts(result)
|
||||
);
|
||||
});
|
||||
}
|
||||
interface FileBytes {
|
||||
id: string;
|
||||
hash: string;
|
||||
byteSize: number;
|
||||
body?: string;
|
||||
permalink?: string;
|
||||
messageId?: string;
|
||||
}
|
||||
interface Transfer {
|
||||
source: ChatSlackIngressReceipt;
|
||||
input: FileBytes;
|
||||
storedInput?: IssueAttachment;
|
||||
actualBriefRead: boolean;
|
||||
attachments: IssueAttachment[];
|
||||
products: IssueWorkProduct[];
|
||||
outputs: FileBytes[];
|
||||
delivered: FileBytes[];
|
||||
firstRunIds: string[];
|
||||
second?: ChatSlackIngressReceipt;
|
||||
oversized?: FileBytes;
|
||||
}
|
||||
interface Observation extends ExternalObservation {
|
||||
transfer?: Transfer;
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof prepareSlackFixture>>;
|
||||
interface PrivateState {
|
||||
fixture: Fixture;
|
||||
input?: FileBytes;
|
||||
oversized?: FileBytes;
|
||||
delivered: Map<string, FileBytes>;
|
||||
actualBriefRead: boolean;
|
||||
firstRunIds: string[];
|
||||
firstArtifactIds: string[];
|
||||
lastProviderRead: number;
|
||||
}
|
||||
const fixtures = new WeakMap<ChatCatalogContext, PrivateState>();
|
||||
function owned(ctx: ChatCatalogContext) {
|
||||
const value = fixtures.get(ctx);
|
||||
if (!value) throw new Error("Actual Slack file fixture is missing");
|
||||
return value;
|
||||
}
|
||||
export function oversizedBriefAnswer(text: string, permalink: string) {
|
||||
const clauses = text.split(/[.!?;\n]+|\bbut\b/i);
|
||||
const falseRead = clauses.some(
|
||||
(clause) =>
|
||||
!/\b(?:not|cannot|can't|couldn't|unable|no|never|nothing)\b/i.test(
|
||||
clause,
|
||||
) &&
|
||||
/\b(?:read|processed|used|converted|finished|completed)\b.*\b(?:new|oversized|large|PDF|second|attached)\b|\b(?:new|second)\s+(?:result|file|memo)\s+(?:is\s+)?(?:ready|finished|complete)/i.test(
|
||||
clause,
|
||||
),
|
||||
);
|
||||
return (
|
||||
/too\s+large|size\s+limit|exceeds?[^.!?\n]{0,60}limit|could not[^.!?\n]{0,60}(?:read|download)|cannot[^.!?\n]{0,60}(?:read|download)/i.test(
|
||||
text,
|
||||
) &&
|
||||
text.includes(permalink) &&
|
||||
!falseRead
|
||||
);
|
||||
}
|
||||
export function gradeSlackFile(value: unknown, supportedOnly = false) {
|
||||
const c = checks(),
|
||||
o = externalObservation(value) as Observation | undefined,
|
||||
t = o?.transfer;
|
||||
c.add(
|
||||
"actual-file-transfer-observations",
|
||||
!!o &&
|
||||
!!t &&
|
||||
[t.attachments, t.products, t.outputs, t.delivered, t.firstRunIds].every(
|
||||
Array.isArray,
|
||||
),
|
||||
"Actual author/thread-bound provider bytes, persisted task attachments, native reads and generated publication are required.",
|
||||
);
|
||||
if (
|
||||
!o ||
|
||||
!t ||
|
||||
![t.attachments, t.products, t.outputs, t.delivered, t.firstRunIds].every(
|
||||
Array.isArray,
|
||||
)
|
||||
)
|
||||
return c.result();
|
||||
const input = t.storedInput;
|
||||
c.add(
|
||||
"supported-inbound-exact-bytes",
|
||||
t.source.query === o.state.originalQuery &&
|
||||
t.input.hash === digest(brief) &&
|
||||
t.input.byteSize === Buffer.byteLength(brief) &&
|
||||
input?.issueId === t.source.issueId &&
|
||||
input.issueCommentId === t.source.humanCommentId &&
|
||||
input.createdByUserId === t.source.linkedPaperclipUserId &&
|
||||
input.sha256 === t.input.hash &&
|
||||
input.byteSize === t.input.byteSize &&
|
||||
t.actualBriefRead,
|
||||
"The genuine Slack upload is downloaded independently, admitted under the exact human comment and read through an actual completed native tool result.",
|
||||
);
|
||||
const output = t.attachments.find(
|
||||
(file) =>
|
||||
file.createdByAgentId &&
|
||||
t.products.some(
|
||||
(product) =>
|
||||
product.type === "artifact" &&
|
||||
product.issueId === file.issueId &&
|
||||
product.createdByRunId === file.originatingRunId &&
|
||||
product.metadata?.attachmentId === file.id,
|
||||
) &&
|
||||
o.runs.some(
|
||||
(run) =>
|
||||
run.id === file.originatingRunId &&
|
||||
run.agentId === file.createdByAgentId &&
|
||||
run.contextSnapshot?.issueId === file.issueId &&
|
||||
run.status === "succeeded" &&
|
||||
(!t.firstRunIds.length || t.firstRunIds.includes(run.id)),
|
||||
) &&
|
||||
t.outputs.some(
|
||||
(actual) =>
|
||||
actual.id === file.id &&
|
||||
actual.hash === file.sha256 &&
|
||||
actual.byteSize === file.byteSize &&
|
||||
!!actual.body &&
|
||||
digest(actual.body) === file.sha256 &&
|
||||
hasLaunchMemoFacts(actual.body),
|
||||
) &&
|
||||
t.delivered.some(
|
||||
(actual) =>
|
||||
actual.hash === file.sha256 && actual.byteSize === file.byteSize,
|
||||
),
|
||||
);
|
||||
c.add(
|
||||
"generated-file-really-delivered",
|
||||
!!output,
|
||||
"A real run-attributed inspectable artifact contains the actual brief facts; an independently downloaded app-bot file in the same Slack thread has identical bytes. A filename or link alone cannot pass.",
|
||||
);
|
||||
const sourceIds = new Set([t.source.issueId]);
|
||||
for (const task of o.tasks)
|
||||
if (task.parentId === t.source.issueId) sourceIds.add(task.id);
|
||||
c.add(
|
||||
"only-attributed-native-work",
|
||||
o.runs.length >= 1 &&
|
||||
o.runs.every(
|
||||
(run) =>
|
||||
run.companyId === o.companyId &&
|
||||
run.runtimeMode === "native" &&
|
||||
run.status === "succeeded" &&
|
||||
sourceIds.has(String(run.contextSnapshot?.issueId)),
|
||||
),
|
||||
"Every measured native belongs to this source or its actual directly delegated task.",
|
||||
);
|
||||
if (supportedOnly) return c.result();
|
||||
const next = t.second,
|
||||
oversized = t.oversized;
|
||||
const later = o.runs.filter(
|
||||
(run) =>
|
||||
!t.firstRunIds.includes(run.id) &&
|
||||
run.contextSnapshot?.issueId === t.source.issueId,
|
||||
);
|
||||
const omitted = later.some(
|
||||
(run) =>
|
||||
Array.isArray(run.contextSnapshot?.externalAttachmentOmissions) &&
|
||||
run.contextSnapshot.externalAttachmentOmissions.some(
|
||||
(entry: unknown) =>
|
||||
row(entry).commentId === next?.humanCommentId &&
|
||||
row(row(entry).reasons).declared_too_large === 1,
|
||||
),
|
||||
);
|
||||
c.add(
|
||||
"actual-oversized-upload-is-not-read",
|
||||
!!next &&
|
||||
next.query === t.source.query &&
|
||||
next.issueId === t.source.issueId &&
|
||||
next.provider.threadId === t.source.provider.threadId &&
|
||||
next.provider.canonicalMessageId !==
|
||||
t.source.provider.canonicalMessageId &&
|
||||
!!oversized &&
|
||||
oversized.byteSize === MAX_ATTACHMENT_BYTES + 1 &&
|
||||
!!oversized.permalink &&
|
||||
omitted &&
|
||||
!t.attachments.some(
|
||||
(file) => file.issueCommentId === next.humanCommentId,
|
||||
) &&
|
||||
later.length >= 1 &&
|
||||
t.attachments
|
||||
.filter((file) => file.createdByAgentId)
|
||||
.every((file) => t.firstRunIds.includes(file.originatingRunId ?? "")),
|
||||
"A second genuine oversized provider file is rejected by the actual intake size gate; no inaccessible brief or new finished result is silently manufactured.",
|
||||
);
|
||||
c.add(
|
||||
"truthful-failure-and-own-download-alternative",
|
||||
!!oversized?.permalink &&
|
||||
o.phaseReplies.some(
|
||||
(reply) =>
|
||||
later.some((run) => run.id === reply.runId) &&
|
||||
oversizedBriefAnswer(reply.body, oversized.permalink!),
|
||||
),
|
||||
"The second actual model phase explains its unavailable oversized brief and links that exact provider-owned file's supported download alternative, never a prior result relabelled as the new result.",
|
||||
);
|
||||
return c.result();
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
ctx: ChatCatalogContext,
|
||||
state: Record<string, unknown>,
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
oversized: boolean,
|
||||
) {
|
||||
if (query !== ctx.scenario.query)
|
||||
throw new Error("Slack file phase changed canonical query bytes");
|
||||
const local = owned(ctx),
|
||||
f = local.fixture,
|
||||
start = f.provider.receipts.length;
|
||||
if (oversized) f.relay.nextFilePhase();
|
||||
f.relay.expectCanonical(query, f.threadId);
|
||||
const bytes = oversized
|
||||
? Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, 32)
|
||||
: Buffer.from(brief);
|
||||
if (oversized)
|
||||
bytes.write("%PDF-1.4\n% Deliberately oversized disposable brief.\n");
|
||||
const uploaded = await f.provider.uploadBrief({
|
||||
filename: oversized ? "oversized-brief.pdf" : "launch-brief.md",
|
||||
bytes,
|
||||
query,
|
||||
threadId: f.threadId,
|
||||
signal,
|
||||
});
|
||||
const actual = await pollUntil({
|
||||
label: "genuine uploaded Slack file and exact canonical human comment",
|
||||
deadlineAt: Date.now() + 60000,
|
||||
load: () => f.readThread(f.threadId),
|
||||
accept: (rows) =>
|
||||
rows.some(
|
||||
(message) =>
|
||||
message.user === f.binding.prerequisites.senderUserId &&
|
||||
message.text === query &&
|
||||
message.files?.some((file) => file.id === uploaded.id),
|
||||
),
|
||||
});
|
||||
const message = actual.find(
|
||||
(message) =>
|
||||
message.user === f.binding.prerequisites.senderUserId &&
|
||||
message.text === query &&
|
||||
message.files?.some((file) => file.id === uploaded.id),
|
||||
)!;
|
||||
const independentlyRead = await f.provider.readThreadFile(
|
||||
f.threadId,
|
||||
uploaded.id,
|
||||
"sender",
|
||||
oversized ? MAX_ATTACHMENT_BYTES + 1 : 262144,
|
||||
);
|
||||
if (
|
||||
digest(independentlyRead.bytes) !== digest(bytes) ||
|
||||
independentlyRead.messageId !== message.ts
|
||||
)
|
||||
throw new Error(
|
||||
"Actual Slack upload bytes or human message identity changed",
|
||||
);
|
||||
const receipt: FileBytes = {
|
||||
id: uploaded.id,
|
||||
hash: digest(bytes),
|
||||
byteSize: bytes.length,
|
||||
permalink: independentlyRead.permalink,
|
||||
messageId: message.ts,
|
||||
};
|
||||
if (oversized) local.oversized = receipt;
|
||||
else local.input = receipt;
|
||||
await pollUntil({
|
||||
label: "genuine signed Slack file delivery",
|
||||
deadlineAt: Date.now() + 30000,
|
||||
load: async () => f.relay.receipt(),
|
||||
accept: (row) => row.canonicalDeliveryCaptured,
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
return f.resolveIngress(
|
||||
query,
|
||||
message.ts,
|
||||
f.provider.receipts.length - start,
|
||||
);
|
||||
}
|
||||
async function observe(
|
||||
ctx: ChatCatalogContext,
|
||||
state: Record<string, unknown>,
|
||||
): Promise<Observation> {
|
||||
const o = await observeExternal(ctx, state),
|
||||
local = owned(ctx),
|
||||
f = local.fixture,
|
||||
source = state.slackIngressReceipt as ChatSlackIngressReceipt | undefined;
|
||||
if (!source || !local.input) return o;
|
||||
const ids = new Set([source.issueId]);
|
||||
for (const task of o.tasks)
|
||||
if (task.parentId === source.issueId) ids.add(task.id);
|
||||
const attachments: IssueAttachment[] = [],
|
||||
products: IssueWorkProduct[] = [],
|
||||
outputs: FileBytes[] = [];
|
||||
for (const id of ids) {
|
||||
attachments.push(
|
||||
...(await ctx.input.api.get<IssueAttachment[]>(
|
||||
`/api/issues/${id}/attachments`,
|
||||
)),
|
||||
);
|
||||
products.push(
|
||||
...(await ctx.input.api.get<IssueWorkProduct[]>(
|
||||
`/api/issues/${id}/work-products`,
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (attachments.length > 20)
|
||||
throw new Error(
|
||||
"Slack file artifact inventory exceeds finite inspection bound",
|
||||
);
|
||||
for (const file of attachments.filter((file) => file.createdByAgentId)) {
|
||||
if (
|
||||
file.byteSize > 262144 ||
|
||||
!/^(?:text\/(?:plain|markdown)|application\/json)$/.test(file.contentType)
|
||||
)
|
||||
continue;
|
||||
const bytes = await ctx.input.api.getAttachmentContent(file.id);
|
||||
outputs.push({
|
||||
id: file.id,
|
||||
hash: digest(bytes),
|
||||
byteSize: bytes.length,
|
||||
body: bytes.toString("utf8"),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!local.actualBriefRead &&
|
||||
o.runs.length &&
|
||||
o.runs.every((run) => run.status === "succeeded")
|
||||
) {
|
||||
for (const run of o.runs) {
|
||||
const events = await collectRunEvents<Record<string, unknown>>(
|
||||
(afterSeq) =>
|
||||
privateScanJson(
|
||||
ctx,
|
||||
`/api/heartbeat-runs/${run.id}/events?afterSeq=${afterSeq}&limit=1000`,
|
||||
),
|
||||
);
|
||||
local.actualBriefRead ||= observedSlackBriefRead(events);
|
||||
}
|
||||
}
|
||||
if (
|
||||
outputs.length &&
|
||||
o.runs.every((run) => run.status === "succeeded") &&
|
||||
Date.now() - local.lastProviderRead > 2000
|
||||
) {
|
||||
local.lastProviderRead = Date.now();
|
||||
const messages = await f.readThread(f.threadId);
|
||||
for (const message of messages.filter(
|
||||
(message) => message.user === f.binding.prerequisites.botUserId,
|
||||
))
|
||||
for (const file of message.files ?? []) {
|
||||
if (local.delivered.has(file.id)) continue;
|
||||
const actual = await f.provider.readThreadFile(
|
||||
f.threadId,
|
||||
file.id,
|
||||
"bot",
|
||||
);
|
||||
local.delivered.set(file.id, {
|
||||
id: file.id,
|
||||
hash: digest(actual.bytes),
|
||||
byteSize: actual.byteSize,
|
||||
permalink: actual.permalink,
|
||||
messageId: actual.messageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
...o,
|
||||
transfer: {
|
||||
source,
|
||||
input: local.input,
|
||||
storedInput: attachments.find(
|
||||
(file) => file.issueCommentId === source.humanCommentId,
|
||||
),
|
||||
actualBriefRead: local.actualBriefRead,
|
||||
attachments,
|
||||
products,
|
||||
outputs,
|
||||
delivered: [...local.delivered.values()],
|
||||
firstRunIds: local.firstRunIds,
|
||||
second: state.slackUnsupportedIngressReceipt as
|
||||
ChatSlackIngressReceipt | undefined,
|
||||
oversized: local.oversized,
|
||||
},
|
||||
};
|
||||
}
|
||||
export const slackFileExternalCases = [
|
||||
definition(
|
||||
"chat-channels.channel-file",
|
||||
async (ctx) => {
|
||||
const fixture = await prepareSlackFixture(ctx);
|
||||
fixtures.set(ctx, {
|
||||
fixture,
|
||||
delivered: new Map(),
|
||||
actualBriefRead: false,
|
||||
firstRunIds: [],
|
||||
firstArtifactIds: [],
|
||||
lastProviderRead: 0,
|
||||
});
|
||||
return { originalQuery: ctx.scenario.query };
|
||||
},
|
||||
gradeSlackFile,
|
||||
{
|
||||
authentication: "human-session",
|
||||
journeyKind: "native-slack-thread",
|
||||
slackIngress: {
|
||||
kind: "native-slack-thread",
|
||||
maximumProviderCalls: 20,
|
||||
ingressTimeoutMs: 120000,
|
||||
},
|
||||
expectedRunCount: 6,
|
||||
minimumRunCount: 2,
|
||||
expectedHumanMessageCount: 2,
|
||||
turnTimeoutMs: 120000,
|
||||
attemptTimeoutMs: 900000,
|
||||
externalInput: { provider: "slack", fields: slackCompanionFields },
|
||||
sourceFiles: [
|
||||
"chat-catalog-domains/external-slack-file.ts",
|
||||
"chat-catalog-domains/external-slack-provider.ts",
|
||||
"chat-catalog-domains/external-slack-fixture.ts",
|
||||
"chat-catalog-domains/external-slack-relay.ts",
|
||||
"chat-catalog-domains/external-fixtures.ts",
|
||||
"chat-catalog-domains/external-secret-scan.ts",
|
||||
"chat-catalog-domains/external-support.ts",
|
||||
"chat-catalog-domains/internal-support.ts",
|
||||
"chat-slack-ingress.ts",
|
||||
"run-observations.ts",
|
||||
"native-event-envelope.ts",
|
||||
"../../server/src/attachment-types.ts",
|
||||
"../../server/src/services/chat-channels/inbound-attachments.ts",
|
||||
"../../server/src/services/chat-channels/inbound/wakeups.ts",
|
||||
"../../server/src/services/issue-assignment-wakeup.ts",
|
||||
"../../server/src/services/chat-channels/slack/file-receipts.ts",
|
||||
"../../server/src/services/chat-channels/outbound-attachments.ts",
|
||||
"chat-scenarios/chat-channels.yaml",
|
||||
],
|
||||
dispatchSlackQuery: (ctx, state, { query, signal }) =>
|
||||
dispatch(ctx, state, query, signal, false),
|
||||
async verifySlackSupportedTransfer(ctx, state) {
|
||||
const value = await observe(ctx, state),
|
||||
grade = gradeSlackFile(value, true);
|
||||
if (grade.passed) {
|
||||
const local = owned(ctx);
|
||||
local.firstRunIds = value.runs.map((run) => run.id);
|
||||
local.firstArtifactIds = value
|
||||
.transfer!.attachments.filter((file) => file.createdByAgentId)
|
||||
.map((file) => file.id);
|
||||
}
|
||||
return grade;
|
||||
},
|
||||
dispatchSlackUnsupportedFile: (ctx, state, { query, signal }) =>
|
||||
dispatch(ctx, state, query, signal, true),
|
||||
observe,
|
||||
async verifyResult(ctx, _state, value) {
|
||||
await verifyRenderedReply(ctx, value);
|
||||
const o = value as Observation,
|
||||
t = o.transfer;
|
||||
const result = t?.attachments.find((file) =>
|
||||
t.outputs.some(
|
||||
(actual) =>
|
||||
actual.id === file.id &&
|
||||
actual.hash === file.sha256 &&
|
||||
t.delivered.some((sent) => sent.hash === actual.hash),
|
||||
),
|
||||
);
|
||||
if (!result || !t?.oversized?.permalink)
|
||||
throw new Error(
|
||||
"Actual original-thread output and oversized file alternative are missing",
|
||||
);
|
||||
const page = await ctx.input.page.context().newPage();
|
||||
try {
|
||||
const pending = page
|
||||
.waitForEvent("download", { timeout: 5000 })
|
||||
.catch(() => undefined);
|
||||
await page
|
||||
.goto(new URL(result.contentPath, o.origin).href)
|
||||
.catch((error) => {
|
||||
if (!/download|ERR_ABORTED/i.test(String(error))) throw error;
|
||||
});
|
||||
const downloaded = await pending;
|
||||
const path = await downloaded?.path();
|
||||
const bytes = path
|
||||
? await readFile(path)
|
||||
: await ctx.input.api.getAttachmentContent(result.id);
|
||||
if (
|
||||
digest(bytes) !== result.sha256 ||
|
||||
!hasLaunchMemoFacts(bytes.toString("utf8"))
|
||||
)
|
||||
throw new Error(
|
||||
"Public result retrieval differs from the generated and Slack-delivered memo",
|
||||
);
|
||||
if (!downloaded)
|
||||
await expect(page.getByText(/Acorn Notes/).first()).toBeVisible();
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
await expect(
|
||||
ctx.input.page
|
||||
.locator(
|
||||
`[id^="comment-"] a[href=${JSON.stringify(t.oversized.permalink)}]`,
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await ctx.input.evidence("chat-catalog-slack-files-visible.json", {
|
||||
resultAttachmentId: result.id,
|
||||
resultHash: result.sha256,
|
||||
oversizedFileId: t.oversized.id,
|
||||
alternativeIsExactOversizedFile: true,
|
||||
});
|
||||
},
|
||||
async cleanup(ctx) {
|
||||
try {
|
||||
await fixtures.get(ctx)?.fixture.cleanup();
|
||||
} finally {
|
||||
fixtures.delete(ctx);
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -1,42 +1,336 @@
|
||||
import { describe,expect,it } from "vitest";
|
||||
import type { ChatConversation,ChatEndpoint,ExternalChannelBindingSummary } from "../../../packages/shared/src/types/chat-channels.js";
|
||||
import { assertSlackCall,assertSlackFixture,slackIngressMatches,type SlackIngressReceipt } from "./external-slack-provider.js";
|
||||
function binding(){return {disposable:true as const,resourceIds:["TWORKSPACE","CCHANNEL","USENDER","UBOT","AAPP"],credentialEnv:["PAPERCLIP_CHAT_EVAL_SLACK_BOT","PAPERCLIP_CHAT_EVAL_SLACK_SENDER","PAPERCLIP_CHAT_EVAL_SLACK_SIGNING","PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"],prerequisites:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",senderUserId:"USENDER",botUserId:"UBOT",appId:"AAPP",registeredWebhookOrigin:"https://isolated.example.test",webhookRelayPort:"3399",botTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_BOT",senderTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SENDER",signingSecretEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SIGNING",configurationTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"}};}
|
||||
function ingress(){const receipt:SlackIngressReceipt={providerCalls:1,query:"Exact canonical query.",endpointId:"endpoint",conversationId:"conversation",issueId:"issue",humanCommentId:"comment",linkedPaperclipUserId:"human",provider:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",threadId:"1793050000.123456",canonicalMessageId:"1793050001.123456",senderUserId:"USENDER"}};return {receipt,endpoint:{id:"endpoint",companyId:"company",assignedAgentId:"ceo",provider:"slack",providerAccountId:"TWORKSPACE",botExternalId:"UBOT"} as ChatEndpoint,conversation:{id:"conversation",endpointId:"endpoint",companyId:"company",issueId:"issue",externalConversationId:"slack:CCHANNEL",externalThreadId:"slack:CCHANNEL:1793050000.123456"} as ChatConversation,binding:{endpointId:"endpoint",conversationId:"conversation",provider:"slack",assignedAgentLocked:true} as ExternalChannelBindingSummary,comment:{id:"comment",body:"Exact canonical query.",authorUserId:"human",metadata:{sourceChannel:"slack"}},canonicalQuery:"Exact canonical query.",companyId:"company",agentId:"ceo"};}
|
||||
describe("finite real Slack companion prerequisites and source binding",()=>{
|
||||
it("requires each explicit disposable provider resource and a distinct actual human sender",()=>{
|
||||
expect(()=>assertSlackFixture(binding())).not.toThrow();const other=binding();other.resourceIds=other.resourceIds.filter(id=>id!=="USENDER");expect(()=>assertSlackFixture(other)).toThrow(/allowlisted/);
|
||||
const bot=binding();bot.prerequisites.senderUserId="UBOT";expect(()=>assertSlackFixture(bot)).toThrow(/real linked human/);
|
||||
});
|
||||
it("refuses arbitrary Slack methods, another app/channel and unscoped post requests",()=>{
|
||||
expect(()=>assertSlackCall(binding(),"sender","chat.postMessage",{channel:"CCHANNEL",text:"Exact original query"})).not.toThrow();
|
||||
for(const action of [()=>assertSlackCall(binding(),"sender","users.list",{}),()=>assertSlackCall(binding(),"sender","chat.postMessage",{channel:"COTHER"}),()=>assertSlackCall(binding(),"sender","chat.postMessage",{}),()=>assertSlackCall(binding(),"configuration","apps.manifest.update",{app_id:"AOTHER"})])expect(action).toThrow();
|
||||
});
|
||||
it("accepts exact canonical linked-human source identity across the actual public DTOs",()=>expect(slackIngressMatches(ingress())).toBe(true));
|
||||
it("rejects copied company hints, wrong sender/comment, wrong thread, lost canonical bytes and missing dispatch",()=>{
|
||||
for(const mutate of [
|
||||
(i:ReturnType<typeof ingress>)=>{i.endpoint.companyId="another-company";},
|
||||
(i:ReturnType<typeof ingress>)=>{i.receipt.provider.senderUserId="UBOT";},
|
||||
(i:ReturnType<typeof ingress>)=>{i.comment.authorUserId="another-human";},
|
||||
(i:ReturnType<typeof ingress>)=>{i.comment.body="A rewritten easier query.";},
|
||||
(i:ReturnType<typeof ingress>)=>{i.conversation.externalThreadId="slack:CCHANNEL:1793050009.123456";},
|
||||
(i:ReturnType<typeof ingress>)=>{i.conversation.externalConversationId="CCHANNEL";},
|
||||
(i:ReturnType<typeof ingress>)=>{i.binding.conversationId="another-conversation";},
|
||||
(i:ReturnType<typeof ingress>)=>{i.receipt.providerCalls=0;},
|
||||
]){const i=ingress();mutate(i);expect(slackIngressMatches(i)).toBe(false);}
|
||||
});
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ChatConversation,
|
||||
ChatEndpoint,
|
||||
ExternalChannelBindingSummary,
|
||||
} from "../../../packages/shared/src/types/chat-channels.js";
|
||||
import {
|
||||
assertSlackCall,
|
||||
assertSlackFixture,
|
||||
slackIngressMatches,
|
||||
slackFileDestination,
|
||||
type SlackIngressReceipt,
|
||||
} from "./external-slack-provider.js";
|
||||
function binding() {
|
||||
return {
|
||||
disposable: true as const,
|
||||
resourceIds: ["TWORKSPACE", "CCHANNEL", "USENDER", "UBOT", "AAPP"],
|
||||
credentialEnv: [
|
||||
"PAPERCLIP_CHAT_EVAL_SLACK_BOT",
|
||||
"PAPERCLIP_CHAT_EVAL_SLACK_SENDER",
|
||||
"PAPERCLIP_CHAT_EVAL_SLACK_SIGNING",
|
||||
"PAPERCLIP_CHAT_EVAL_SLACK_CONFIG",
|
||||
],
|
||||
prerequisites: {
|
||||
workspaceId: "TWORKSPACE",
|
||||
channelId: "CCHANNEL",
|
||||
senderUserId: "USENDER",
|
||||
botUserId: "UBOT",
|
||||
appId: "AAPP",
|
||||
registeredWebhookOrigin: "https://isolated.example.test",
|
||||
webhookRelayPort: "3399",
|
||||
botTokenEnv: "PAPERCLIP_CHAT_EVAL_SLACK_BOT",
|
||||
senderTokenEnv: "PAPERCLIP_CHAT_EVAL_SLACK_SENDER",
|
||||
signingSecretEnv: "PAPERCLIP_CHAT_EVAL_SLACK_SIGNING",
|
||||
configurationTokenEnv: "PAPERCLIP_CHAT_EVAL_SLACK_CONFIG",
|
||||
},
|
||||
};
|
||||
}
|
||||
function ingress() {
|
||||
const receipt: SlackIngressReceipt = {
|
||||
providerCalls: 1,
|
||||
query: "Exact canonical query.",
|
||||
endpointId: "endpoint",
|
||||
conversationId: "conversation",
|
||||
issueId: "issue",
|
||||
humanCommentId: "comment",
|
||||
linkedPaperclipUserId: "human",
|
||||
provider: {
|
||||
workspaceId: "TWORKSPACE",
|
||||
channelId: "CCHANNEL",
|
||||
threadId: "1793050000.123456",
|
||||
canonicalMessageId: "1793050001.123456",
|
||||
senderUserId: "USENDER",
|
||||
},
|
||||
};
|
||||
return {
|
||||
receipt,
|
||||
endpoint: {
|
||||
id: "endpoint",
|
||||
companyId: "company",
|
||||
assignedAgentId: "ceo",
|
||||
provider: "slack",
|
||||
providerAccountId: "TWORKSPACE",
|
||||
botExternalId: "UBOT",
|
||||
} as ChatEndpoint,
|
||||
conversation: {
|
||||
id: "conversation",
|
||||
endpointId: "endpoint",
|
||||
companyId: "company",
|
||||
issueId: "issue",
|
||||
externalConversationId: "slack:CCHANNEL",
|
||||
externalThreadId: "slack:CCHANNEL:1793050000.123456",
|
||||
} as ChatConversation,
|
||||
binding: {
|
||||
endpointId: "endpoint",
|
||||
conversationId: "conversation",
|
||||
provider: "slack",
|
||||
assignedAgentLocked: true,
|
||||
} as ExternalChannelBindingSummary,
|
||||
comment: {
|
||||
id: "comment",
|
||||
body: "Exact canonical query.",
|
||||
authorUserId: "human",
|
||||
metadata: { sourceChannel: "slack" },
|
||||
},
|
||||
canonicalQuery: "Exact canonical query.",
|
||||
companyId: "company",
|
||||
agentId: "ceo",
|
||||
};
|
||||
}
|
||||
describe("finite real Slack companion prerequisites and source binding", () => {
|
||||
it("requires each explicit disposable provider resource and a distinct actual human sender", () => {
|
||||
expect(() => assertSlackFixture(binding())).not.toThrow();
|
||||
const other = binding();
|
||||
other.resourceIds = other.resourceIds.filter((id) => id !== "USENDER");
|
||||
expect(() => assertSlackFixture(other)).toThrow(/allowlisted/);
|
||||
const bot = binding();
|
||||
bot.prerequisites.senderUserId = "UBOT";
|
||||
expect(() => assertSlackFixture(bot)).toThrow(/real linked human/);
|
||||
});
|
||||
it("refuses arbitrary Slack methods, another app/channel and unscoped post requests", () => {
|
||||
expect(() =>
|
||||
assertSlackCall(binding(), "sender", "chat.postMessage", {
|
||||
channel: "CCHANNEL",
|
||||
text: "Exact original query",
|
||||
}),
|
||||
).not.toThrow();
|
||||
for (const action of [
|
||||
() => assertSlackCall(binding(), "sender", "users.list", {}),
|
||||
() =>
|
||||
assertSlackCall(binding(), "sender", "chat.postMessage", {
|
||||
channel: "COTHER",
|
||||
}),
|
||||
() => assertSlackCall(binding(), "sender", "chat.postMessage", {}),
|
||||
() =>
|
||||
assertSlackCall(binding(), "configuration", "apps.manifest.update", {
|
||||
app_id: "AOTHER",
|
||||
}),
|
||||
])
|
||||
expect(action).toThrow();
|
||||
});
|
||||
it("accepts exact canonical linked-human source identity across the actual public DTOs", () =>
|
||||
expect(slackIngressMatches(ingress())).toBe(true));
|
||||
it("rejects copied company hints, wrong sender/comment, wrong thread, lost canonical bytes and missing dispatch", () => {
|
||||
for (const mutate of [
|
||||
(i: ReturnType<typeof ingress>) => {
|
||||
i.endpoint.companyId = "another-company";
|
||||
},
|
||||
(i: ReturnType<typeof ingress>) => {
|
||||
i.receipt.provider.senderUserId = "UBOT";
|
||||
},
|
||||
(i: ReturnType<typeof ingress>) => {
|
||||
i.comment.authorUserId = "another-human";
|
||||
},
|
||||
(i: ReturnType<typeof ingress>) => {
|
||||
i.comment.body = "A rewritten easier query.";
|
||||
},
|
||||
(i: ReturnType<typeof ingress>) => {
|
||||
i.conversation.externalThreadId = "slack:CCHANNEL:1793050009.123456";
|
||||
},
|
||||
(i: ReturnType<typeof ingress>) => {
|
||||
i.conversation.externalConversationId = "CCHANNEL";
|
||||
},
|
||||
(i: ReturnType<typeof ingress>) => {
|
||||
i.binding.conversationId = "another-conversation";
|
||||
},
|
||||
(i: ReturnType<typeof ingress>) => {
|
||||
i.receipt.providerCalls = 0;
|
||||
},
|
||||
]) {
|
||||
const i = ingress();
|
||||
mutate(i);
|
||||
expect(slackIngressMatches(i)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans only messages proven created by this exact private client; arbitrary delete remains unavailable",async()=>{
|
||||
const {slackProvider}=await import("./external-slack-provider.js");
|
||||
const b=binding(),requests:Array<{method:string;body:Record<string,unknown>}>=[];
|
||||
const ctx={input:{privateSecrets:[],privateInputs:{read:()=>"synthetic-private-token"}}} as unknown as import("../chat-catalog-contract.js").ChatCatalogContext;
|
||||
const client=slackProvider(ctx,b,(async(url,options)=>{
|
||||
const method=String(url).split("/").at(-1)!,body=JSON.parse(String(options?.body));requests.push({method,body});
|
||||
return new Response(JSON.stringify(method==="chat.postMessage"?{ok:true,channel:"CCHANNEL",ts:"1793050002.123456"}:method==="conversations.replies"?{ok:true,messages:[{ts:"1793050003.123456",thread_ts:"1793050002.123456",user:"UBOT"},{ts:"1793050004.123456",thread_ts:"1793050002.123456",user:"UOTHER"}]}:{ok:true}),{status:200});
|
||||
}) as typeof fetch);
|
||||
await expect(client.call("sender","chat.delete",{channel:"CCHANNEL",ts:"1793059999.123456"})).rejects.toThrow(/finite/);
|
||||
await client.call("sender","chat.postMessage",{channel:"CCHANNEL",text:"Actual disposable creation"});
|
||||
await client.cleanupOwned();await client.cleanupOwned();
|
||||
expect(requests).toEqual([{method:"chat.postMessage",body:{channel:"CCHANNEL",text:"Actual disposable creation"}},{method:"conversations.replies",body:{channel:"CCHANNEL",ts:"1793050002.123456",limit:100}},{method:"chat.delete",body:{channel:"CCHANNEL",ts:"1793050003.123456"}},{method:"chat.delete",body:{channel:"CCHANNEL",ts:"1793050002.123456"}}]);
|
||||
it("bounds official file transport destinations without forwarding a bearer to redirects or lookalike hosts", () => {
|
||||
expect(
|
||||
slackFileDestination("https://files.slack.com/upload/v1/owned").hostname,
|
||||
).toBe("files.slack.com");
|
||||
for (const url of [
|
||||
"http://files.slack.com/file",
|
||||
"https://files.slack.com.evil.test/file",
|
||||
"https://files.slack.com:444/file",
|
||||
"https://user:secret@files.slack.com/file",
|
||||
"https://files.slack.com/file#secret",
|
||||
])
|
||||
expect(() => slackFileDestination(url)).toThrow();
|
||||
});
|
||||
|
||||
it("uploads only actual owned-thread bytes and independently validates bot file author/thread/size before download", async () => {
|
||||
const { slackProvider } = await import("./external-slack-provider.js");
|
||||
const b = binding(),
|
||||
sent: string[] = [],
|
||||
bytes = Buffer.from("Actual finished memo bytes"),
|
||||
secretRegistry: string[] = [];
|
||||
let wrongAuthor = false;
|
||||
const ctx = {
|
||||
input: {
|
||||
privateSecrets: secretRegistry,
|
||||
privateInputs: { read: () => "synthetic-token" },
|
||||
},
|
||||
} as unknown as import("../chat-catalog-contract.js").ChatCatalogContext;
|
||||
const client = slackProvider(ctx, b, (async (url, options) => {
|
||||
const value = String(url);
|
||||
sent.push(value);
|
||||
if (value.startsWith("https://files.slack.com/")) {
|
||||
expect(options?.redirect).toBe("error");
|
||||
return new Response(bytes);
|
||||
}
|
||||
const method = value.split("/").at(-1);
|
||||
const result =
|
||||
method === "chat.postMessage"
|
||||
? { ok: true, channel: "CCHANNEL", ts: "1793050002.123456" }
|
||||
: method === "files.getUploadURLExternal"
|
||||
? {
|
||||
ok: true,
|
||||
file_id: "FUPLOAD",
|
||||
upload_url: "https://files.slack.com/upload/v1/private",
|
||||
}
|
||||
: method === "conversations.replies"
|
||||
? {
|
||||
ok: true,
|
||||
messages: [
|
||||
{
|
||||
ts: "1793050003.123456",
|
||||
thread_ts: "1793050002.123456",
|
||||
user: "UBOT",
|
||||
files: [{ id: "FOUTPUT" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
: method === "files.info"
|
||||
? {
|
||||
ok: true,
|
||||
file: {
|
||||
id: "FOUTPUT",
|
||||
user: wrongAuthor ? "UOTHER" : "UBOT",
|
||||
size: bytes.length,
|
||||
url_private_download:
|
||||
"https://files.slack.com/private/result",
|
||||
permalink:
|
||||
"https://owned.slack.com/files/UBOT/FOUTPUT/result.md",
|
||||
},
|
||||
}
|
||||
: { ok: true };
|
||||
return new Response(JSON.stringify(result));
|
||||
}) as typeof fetch);
|
||||
const args = {
|
||||
filename: "brief.md",
|
||||
bytes,
|
||||
query: "Unchanged query",
|
||||
threadId: "1793050002.123456",
|
||||
signal: AbortSignal.timeout(10000),
|
||||
};
|
||||
await expect(client.uploadBrief(args)).rejects.toThrow(/owned-thread/);
|
||||
expect(sent).toHaveLength(0);
|
||||
await client.call("sender", "chat.postMessage", {
|
||||
channel: "CCHANNEL",
|
||||
text: "Actual owned root",
|
||||
});
|
||||
await expect(client.uploadBrief(args)).resolves.toMatchObject({
|
||||
id: "FUPLOAD",
|
||||
byteSize: bytes.length,
|
||||
});
|
||||
await expect(
|
||||
client.readThreadFile(args.threadId, "FOUTPUT", "bot"),
|
||||
).resolves.toMatchObject({
|
||||
bytes,
|
||||
byteSize: bytes.length,
|
||||
messageId: "1793050003.123456",
|
||||
});
|
||||
expect(secretRegistry).toContain("https://files.slack.com/upload/v1/private");
|
||||
wrongAuthor = true;
|
||||
const count = sent.filter(
|
||||
(url) => url === "https://files.slack.com/private/result",
|
||||
).length;
|
||||
await expect(
|
||||
client.readThreadFile(args.threadId, "FOUTPUT", "bot"),
|
||||
).rejects.toThrow(/author\/size/);
|
||||
expect(
|
||||
sent.filter((url) => url === "https://files.slack.com/private/result"),
|
||||
).toHaveLength(count);
|
||||
});
|
||||
|
||||
it("cleans only messages proven created by this exact private client; arbitrary delete remains unavailable", async () => {
|
||||
const { slackProvider } = await import("./external-slack-provider.js");
|
||||
const b = binding(),
|
||||
requests: Array<{ method: string; body: Record<string, unknown> }> = [];
|
||||
const ctx = {
|
||||
input: {
|
||||
privateSecrets: [],
|
||||
privateInputs: { read: () => "synthetic-private-token" },
|
||||
},
|
||||
} as unknown as import("../chat-catalog-contract.js").ChatCatalogContext;
|
||||
const client = slackProvider(ctx, b, (async (url, options) => {
|
||||
const method = String(url).split("/").at(-1)!,
|
||||
body = JSON.parse(String(options?.body));
|
||||
requests.push({ method, body });
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
method === "chat.postMessage"
|
||||
? { ok: true, channel: "CCHANNEL", ts: "1793050002.123456" }
|
||||
: method === "conversations.replies"
|
||||
? {
|
||||
ok: true,
|
||||
messages: [
|
||||
{
|
||||
ts: "1793050003.123456",
|
||||
thread_ts: "1793050002.123456",
|
||||
user: "UBOT",
|
||||
},
|
||||
{
|
||||
ts: "1793050004.123456",
|
||||
thread_ts: "1793050002.123456",
|
||||
user: "UOTHER",
|
||||
},
|
||||
],
|
||||
}
|
||||
: { ok: true },
|
||||
),
|
||||
{ status: 200 },
|
||||
);
|
||||
}) as typeof fetch);
|
||||
await expect(
|
||||
client.call("sender", "chat.delete", {
|
||||
channel: "CCHANNEL",
|
||||
ts: "1793059999.123456",
|
||||
}),
|
||||
).rejects.toThrow(/finite/);
|
||||
await client.call("sender", "chat.postMessage", {
|
||||
channel: "CCHANNEL",
|
||||
text: "Actual disposable creation",
|
||||
});
|
||||
await client.cleanupOwned();
|
||||
await client.cleanupOwned();
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: "chat.postMessage",
|
||||
body: { channel: "CCHANNEL", text: "Actual disposable creation" },
|
||||
},
|
||||
{
|
||||
method: "conversations.replies",
|
||||
body: { channel: "CCHANNEL", ts: "1793050002.123456", limit: 100 },
|
||||
},
|
||||
{
|
||||
method: "chat.delete",
|
||||
body: { channel: "CCHANNEL", ts: "1793050003.123456" },
|
||||
},
|
||||
{
|
||||
method: "chat.delete",
|
||||
body: { channel: "CCHANNEL", ts: "1793050002.123456" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1,73 +1,563 @@
|
||||
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
import type { ChatConversation,ChatEndpoint,ExternalChannelBindingSummary } from "../../../packages/shared/src/types/chat-channels.js";
|
||||
import { selectedExternalCredential,type ProviderBinding } from "./external-fixtures.js";
|
||||
import type {
|
||||
ChatConversation,
|
||||
ChatEndpoint,
|
||||
ExternalChannelBindingSummary,
|
||||
} from "../../../packages/shared/src/types/chat-channels.js";
|
||||
import {
|
||||
selectedExternalCredential,
|
||||
type ProviderBinding,
|
||||
} from "./external-fixtures.js";
|
||||
|
||||
export const slackCompanionFields=["workspaceId","channelId","senderUserId","botUserId","appId","registeredWebhookOrigin","webhookRelayPort","botTokenEnv","senderTokenEnv","signingSecretEnv","configurationTokenEnv"] as const;
|
||||
const methods={bot:new Set(["auth.test","users.info","bots.info","conversations.info","conversations.replies","conversations.history","chat.postMessage","files.info"]),sender:new Set(["auth.test","chat.postMessage","files.getUploadURLExternal","files.completeUploadExternal"]),configuration:new Set(["apps.manifest.export","apps.manifest.update"])};
|
||||
export function assertSlackFixture(binding:ProviderBinding){
|
||||
const p=binding.prerequisites;
|
||||
for(const field of slackCompanionFields)if(!p[field])throw new Error(`Setup blocker: Slack ${field} reference is required`);
|
||||
for(const [field,pattern] of [["workspaceId",/^T[A-Z0-9]+$/],["channelId",/^[CG][A-Z0-9]+$/],["senderUserId",/^[UW][A-Z0-9]+$/],["botUserId",/^[UW][A-Z0-9]+$/],["appId",/^A[A-Z0-9]+$/]] as const){if(!pattern.test(p[field]!)||!binding.resourceIds.includes(p[field]!))throw new Error(`Setup blocker: Slack ${field} is not an explicitly allowlisted disposable provider resource`);}
|
||||
const origin=new URL(p.registeredWebhookOrigin!);if(origin.protocol!=="https:"||origin.username||origin.password||origin.search||origin.hash||origin.pathname!=="/")throw new Error("Setup blocker: Slack requires an explicitly designated HTTPS webhook origin for this isolated attempt");
|
||||
if(p.botUserId===p.senderUserId)throw new Error("Setup blocker: canonical Slack sender must be the real linked human, not the app bot");
|
||||
export const slackCompanionFields = [
|
||||
"workspaceId",
|
||||
"channelId",
|
||||
"senderUserId",
|
||||
"botUserId",
|
||||
"appId",
|
||||
"registeredWebhookOrigin",
|
||||
"webhookRelayPort",
|
||||
"botTokenEnv",
|
||||
"senderTokenEnv",
|
||||
"signingSecretEnv",
|
||||
"configurationTokenEnv",
|
||||
] as const;
|
||||
export function slackFileDestination(value: string) {
|
||||
const url = new URL(value);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.hostname !== "files.slack.com" ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port ||
|
||||
url.hash
|
||||
)
|
||||
throw new Error("Slack file transport escaped its official destination");
|
||||
return url;
|
||||
}
|
||||
export function assertSlackCall(binding:ProviderBinding,kind:keyof typeof methods,method:string,parameters:Record<string,unknown>){
|
||||
assertSlackFixture(binding);if(!methods[kind].has(method))throw new Error("Slack fixture method exceeds its finite provider contract");
|
||||
const p=binding.prerequisites;
|
||||
if("channel" in parameters&¶meters.channel!==p.channelId)throw new Error("Slack fixture call escaped its allowed channel");
|
||||
if("channel_id" in parameters&¶meters.channel_id!==p.channelId)throw new Error("Slack fixture upload escaped its allowed channel");
|
||||
if("app_id" in parameters&¶meters.app_id!==p.appId)throw new Error("Slack fixture call escaped its allowed disposable app");
|
||||
if((method==="apps.manifest.export"||method==="apps.manifest.update")&¶meters.app_id!==p.appId)throw new Error("Slack manifest operation requires the exact owned app");
|
||||
if(method==="chat.postMessage"&¶meters.channel!==p.channelId)throw new Error("Slack canonical input requires the exact allowed channel");
|
||||
const methods = {
|
||||
bot: new Set([
|
||||
"auth.test",
|
||||
"users.info",
|
||||
"bots.info",
|
||||
"conversations.info",
|
||||
"conversations.replies",
|
||||
"conversations.history",
|
||||
"chat.postMessage",
|
||||
"files.info",
|
||||
]),
|
||||
sender: new Set([
|
||||
"auth.test",
|
||||
"chat.postMessage",
|
||||
"files.getUploadURLExternal",
|
||||
"files.completeUploadExternal",
|
||||
]),
|
||||
configuration: new Set(["apps.manifest.export", "apps.manifest.update"]),
|
||||
};
|
||||
export function assertSlackFixture(binding: ProviderBinding) {
|
||||
const p = binding.prerequisites;
|
||||
for (const field of slackCompanionFields)
|
||||
if (!p[field])
|
||||
throw new Error(`Setup blocker: Slack ${field} reference is required`);
|
||||
for (const [field, pattern] of [
|
||||
["workspaceId", /^T[A-Z0-9]+$/],
|
||||
["channelId", /^[CG][A-Z0-9]+$/],
|
||||
["senderUserId", /^[UW][A-Z0-9]+$/],
|
||||
["botUserId", /^[UW][A-Z0-9]+$/],
|
||||
["appId", /^A[A-Z0-9]+$/],
|
||||
] as const) {
|
||||
if (!pattern.test(p[field]!) || !binding.resourceIds.includes(p[field]!))
|
||||
throw new Error(
|
||||
`Setup blocker: Slack ${field} is not an explicitly allowlisted disposable provider resource`,
|
||||
);
|
||||
}
|
||||
const origin = new URL(p.registeredWebhookOrigin!);
|
||||
if (
|
||||
origin.protocol !== "https:" ||
|
||||
origin.username ||
|
||||
origin.password ||
|
||||
origin.search ||
|
||||
origin.hash ||
|
||||
origin.pathname !== "/"
|
||||
)
|
||||
throw new Error(
|
||||
"Setup blocker: Slack requires an explicitly designated HTTPS webhook origin for this isolated attempt",
|
||||
);
|
||||
if (p.botUserId === p.senderUserId)
|
||||
throw new Error(
|
||||
"Setup blocker: canonical Slack sender must be the real linked human, not the app bot",
|
||||
);
|
||||
}
|
||||
export function assertSlackCall(
|
||||
binding: ProviderBinding,
|
||||
kind: keyof typeof methods,
|
||||
method: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) {
|
||||
assertSlackFixture(binding);
|
||||
if (!methods[kind].has(method))
|
||||
throw new Error(
|
||||
"Slack fixture method exceeds its finite provider contract",
|
||||
);
|
||||
const p = binding.prerequisites;
|
||||
if ("channel" in parameters && parameters.channel !== p.channelId)
|
||||
throw new Error("Slack fixture call escaped its allowed channel");
|
||||
if ("channel_id" in parameters && parameters.channel_id !== p.channelId)
|
||||
throw new Error("Slack fixture upload escaped its allowed channel");
|
||||
if ("app_id" in parameters && parameters.app_id !== p.appId)
|
||||
throw new Error("Slack fixture call escaped its allowed disposable app");
|
||||
if (
|
||||
(method === "apps.manifest.export" || method === "apps.manifest.update") &&
|
||||
parameters.app_id !== p.appId
|
||||
)
|
||||
throw new Error("Slack manifest operation requires the exact owned app");
|
||||
if (method === "chat.postMessage" && parameters.channel !== p.channelId)
|
||||
throw new Error("Slack canonical input requires the exact allowed channel");
|
||||
}
|
||||
/** Official Slack HTTP only. No fixture call occurs during import/calibration. */
|
||||
export function slackProvider(ctx:ChatCatalogContext,binding:ProviderBinding,fetcher:typeof fetch=fetch){
|
||||
assertSlackFixture(binding);let calls=0;const receipts:Array<{kind:string;method:string;ok:boolean}>=[];
|
||||
const ownedMessages:Array<{kind:"sender"|"bot";ts:string}>=[],ownedFiles:string[]=[],ownedThreads=new Set<string>();
|
||||
async function invoke<T extends Record<string,unknown>>(kind:keyof typeof methods,method:string,parameters:Record<string,unknown>,signal?:AbortSignal):Promise<T>{
|
||||
if(++calls>120)throw new Error("Slack fixture exceeded its declared bounded provider calls");
|
||||
const token=selectedExternalCredential(ctx,binding,kind==="bot"?"botTokenEnv":kind==="sender"?"senderTokenEnv":"configurationTokenEnv");
|
||||
const response=await fetcher(`https://slack.com/api/${method}`,{method:"POST",redirect:"error",signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000),headers:{authorization:`Bearer ${token}`,"content-type":"application/json; charset=utf-8"},body:JSON.stringify(parameters)});
|
||||
if(!response.ok){receipts.push({kind,method,ok:false});await response.body?.cancel();throw new Error(`Slack fixture ${method} returned HTTP ${response.status}; private response withheld`);}
|
||||
const chunks:Uint8Array[]=[];let size=0;const reader=response.body?.getReader();if(!reader)throw new Error("Slack fixture response is missing");
|
||||
try{for(;;){const part=await reader.read();if(part.done)break;size+=part.value.byteLength;if(size>2*1024*1024)throw new Error("Slack fixture response exceeds bounded metadata size");chunks.push(part.value);}}finally{await reader.cancel();}
|
||||
let result:T;try{result=JSON.parse(Buffer.concat(chunks).toString("utf8")) as T;}catch{throw new Error("Slack fixture returned invalid metadata; response withheld");}
|
||||
receipts.push({kind,method,ok:result.ok===true});if(result.ok!==true)throw new Error(`Slack fixture ${method} was not accepted; provider error details withheld`);return result;
|
||||
}
|
||||
return {receipts,async call<T extends Record<string,unknown>>(kind:keyof typeof methods,method:string,parameters:Record<string,unknown>,signal?:AbortSignal):Promise<T>{
|
||||
assertSlackCall(binding,kind,method,parameters);const result=await invoke<T>(kind,method,parameters,signal);
|
||||
if(method==="chat.postMessage"&&kind!=="configuration"&&result.channel===binding.prerequisites.channelId&&typeof result.ts==="string"){ownedMessages.push({kind,ts:result.ts});ownedThreads.add(typeof parameters.thread_ts==="string"?parameters.thread_ts:result.ts);}
|
||||
if(method==="files.completeUploadExternal"&&kind==="sender"&&Array.isArray(result.files))for(const file of result.files){if(file&&typeof file.id==="string")ownedFiles.push(file.id);}
|
||||
return result;
|
||||
},
|
||||
/** No caller-supplied deletion IDs: only receipts from this client's actual
|
||||
* successful creation calls may be removed. Existing channel content is never
|
||||
* enumerated for cleanup or adopted as owned. */
|
||||
async cleanupOwned(){
|
||||
const failures:string[]=[];
|
||||
// Product-published replies are owned only through this client's genuinely
|
||||
// created roots. Read each exact thread, then delete only this app's bot
|
||||
// messages; never adopt another author's content as disposable.
|
||||
const threads=[...ownedThreads];ownedThreads.clear();
|
||||
for(const ts of threads)try{
|
||||
const thread=await invoke<{ok:true;messages:Array<{ts:string;thread_ts?:string;user?:string}>;has_more?:boolean}>("bot","conversations.replies",{channel:binding.prerequisites.channelId,ts,limit:100});
|
||||
if(thread.has_more)throw new Error("Owned Slack cleanup exceeds finite thread bound");
|
||||
for(const message of thread.messages??[])if(message.user===binding.prerequisites.botUserId&&message.thread_ts===ts)await invoke("bot","chat.delete",{channel:binding.prerequisites.channelId,ts:message.ts});
|
||||
}catch{failures.push("owned-thread-replies");}
|
||||
for(const file of ownedFiles.splice(0).reverse())try{await invoke("sender","files.delete",{file});}catch{failures.push("owned-file");}
|
||||
for(const message of ownedMessages.splice(0).reverse())try{await invoke(message.kind,"chat.delete",{channel:binding.prerequisites.channelId,ts:message.ts});}catch{failures.push("owned-message");}
|
||||
if(failures.length)throw new Error(`Slack disposable cleanup incomplete: ${failures.length} owned resources could not be removed`);
|
||||
}};
|
||||
export function slackProvider(
|
||||
ctx: ChatCatalogContext,
|
||||
binding: ProviderBinding,
|
||||
fetcher: typeof fetch = fetch,
|
||||
) {
|
||||
assertSlackFixture(binding);
|
||||
let calls = 0;
|
||||
const receipts: Array<{ kind: string; method: string; ok: boolean }> = [];
|
||||
const ownedMessages: Array<{ kind: "sender" | "bot"; ts: string }> = [],
|
||||
ownedFiles: string[] = [],
|
||||
ownedBotFiles = new Set<string>(),
|
||||
ownedThreads = new Set<string>();
|
||||
async function invoke<T extends Record<string, unknown>>(
|
||||
kind: keyof typeof methods,
|
||||
method: string,
|
||||
parameters: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
if (++calls > 120)
|
||||
throw new Error(
|
||||
"Slack fixture exceeded its declared bounded provider calls",
|
||||
);
|
||||
const token = selectedExternalCredential(
|
||||
ctx,
|
||||
binding,
|
||||
kind === "bot"
|
||||
? "botTokenEnv"
|
||||
: kind === "sender"
|
||||
? "senderTokenEnv"
|
||||
: "configurationTokenEnv",
|
||||
);
|
||||
const response = await fetcher(`https://slack.com/api/${method}`, {
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(20000)])
|
||||
: AbortSignal.timeout(20000),
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(parameters),
|
||||
});
|
||||
if (!response.ok) {
|
||||
receipts.push({ kind, method, ok: false });
|
||||
await response.body?.cancel();
|
||||
throw new Error(
|
||||
`Slack fixture ${method} returned HTTP ${response.status}; private response withheld`,
|
||||
);
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Slack fixture response is missing");
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
size += part.value.byteLength;
|
||||
if (size > 2 * 1024 * 1024)
|
||||
throw new Error(
|
||||
"Slack fixture response exceeds bounded metadata size",
|
||||
);
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
let result: T;
|
||||
try {
|
||||
result = JSON.parse(Buffer.concat(chunks).toString("utf8")) as T;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Slack fixture returned invalid metadata; response withheld",
|
||||
);
|
||||
}
|
||||
receipts.push({ kind, method, ok: result.ok === true });
|
||||
if (result.ok !== true)
|
||||
throw new Error(
|
||||
`Slack fixture ${method} was not accepted; provider error details withheld`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
receipts,
|
||||
async uploadBrief(input: {
|
||||
filename: string;
|
||||
bytes: Buffer;
|
||||
query: string;
|
||||
threadId: string;
|
||||
signal: AbortSignal;
|
||||
}) {
|
||||
if (
|
||||
!ownedThreads.has(input.threadId) ||
|
||||
!/^[-a-zA-Z0-9_.]{1,100}$/.test(input.filename) ||
|
||||
!input.query ||
|
||||
input.bytes.length < 1 ||
|
||||
input.bytes.length > 12 * 1024 * 1024
|
||||
)
|
||||
throw new Error(
|
||||
"Slack brief upload exceeds its finite owned-thread contract",
|
||||
);
|
||||
const allocated = await invoke<{
|
||||
ok: true;
|
||||
file_id: string;
|
||||
upload_url: string;
|
||||
}>(
|
||||
"sender",
|
||||
"files.getUploadURLExternal",
|
||||
{ filename: input.filename, length: input.bytes.length },
|
||||
input.signal,
|
||||
);
|
||||
if (!/^F[A-Z0-9]+$/.test(allocated.file_id))
|
||||
throw new Error("Slack returned an invalid allocated file identity");
|
||||
ownedFiles.push(allocated.file_id);
|
||||
ctx.input.privateSecrets?.push(allocated.upload_url);
|
||||
const destination = slackFileDestination(allocated.upload_url);
|
||||
if (++calls > 120)
|
||||
throw new Error(
|
||||
"Slack fixture exceeded its declared bounded provider calls",
|
||||
);
|
||||
const response = await fetcher(destination, {
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
signal: AbortSignal.any([input.signal, AbortSignal.timeout(30000)]),
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
body: new Uint8Array(input.bytes),
|
||||
});
|
||||
receipts.push({
|
||||
kind: "sender",
|
||||
method: "private-file-upload",
|
||||
ok: response.ok,
|
||||
});
|
||||
await response.body?.cancel();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
"Slack private brief byte upload failed; response withheld",
|
||||
);
|
||||
await invoke(
|
||||
"sender",
|
||||
"files.completeUploadExternal",
|
||||
{
|
||||
files: [{ id: allocated.file_id, title: input.filename }],
|
||||
channel_id: binding.prerequisites.channelId,
|
||||
thread_ts: input.threadId,
|
||||
initial_comment: input.query,
|
||||
},
|
||||
input.signal,
|
||||
);
|
||||
return { id: allocated.file_id, byteSize: input.bytes.length };
|
||||
},
|
||||
async readThreadFile(
|
||||
threadId: string,
|
||||
fileId: string,
|
||||
author: "sender" | "bot",
|
||||
maximumBytes = 262144,
|
||||
) {
|
||||
if (
|
||||
!ownedThreads.has(threadId) ||
|
||||
!/^F[A-Z0-9]+$/.test(fileId) ||
|
||||
!Number.isInteger(maximumBytes) ||
|
||||
maximumBytes < 1 ||
|
||||
maximumBytes > 12 * 1024 * 1024
|
||||
)
|
||||
throw new Error(
|
||||
"Slack file read exceeds its finite owned-thread contract",
|
||||
);
|
||||
const thread = await invoke<{
|
||||
ok: true;
|
||||
messages: Array<{
|
||||
ts: string;
|
||||
thread_ts?: string;
|
||||
user?: string;
|
||||
files?: Array<{ id: string }>;
|
||||
}>;
|
||||
has_more?: boolean;
|
||||
}>("bot", "conversations.replies", {
|
||||
channel: binding.prerequisites.channelId,
|
||||
ts: threadId,
|
||||
limit: 100,
|
||||
});
|
||||
const expectedAuthor =
|
||||
binding.prerequisites[author === "bot" ? "botUserId" : "senderUserId"];
|
||||
const messages = thread.messages?.filter(
|
||||
(row) =>
|
||||
row.thread_ts === threadId &&
|
||||
row.user === expectedAuthor &&
|
||||
row.files?.some((file) => file.id === fileId),
|
||||
);
|
||||
if (thread.has_more || messages?.length !== 1)
|
||||
throw new Error(
|
||||
"Slack file lacks one actual author-bound message in the owned thread",
|
||||
);
|
||||
const result = await invoke<{
|
||||
ok: true;
|
||||
file: {
|
||||
id: string;
|
||||
user: string;
|
||||
size: number;
|
||||
url_private_download: string;
|
||||
permalink: string;
|
||||
mimetype?: string;
|
||||
name?: string;
|
||||
};
|
||||
}>("bot", "files.info", { file: fileId });
|
||||
const file = result.file;
|
||||
if (
|
||||
file?.id !== fileId ||
|
||||
file.user !== expectedAuthor ||
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size < 1 ||
|
||||
file.size > maximumBytes
|
||||
)
|
||||
throw new Error(
|
||||
"Slack actual file metadata exceeds the expected author/size boundary",
|
||||
);
|
||||
const permalink = new URL(file.permalink);
|
||||
if (
|
||||
permalink.protocol !== "https:" ||
|
||||
!permalink.hostname.endsWith(".slack.com") ||
|
||||
permalink.username ||
|
||||
permalink.password ||
|
||||
permalink.port ||
|
||||
permalink.hash ||
|
||||
permalink.search ||
|
||||
!permalink.pathname.split("/").includes(fileId)
|
||||
)
|
||||
throw new Error("Slack file lacks a supported exact-file permalink");
|
||||
ctx.input.privateSecrets?.push(file.url_private_download);
|
||||
const destination = slackFileDestination(file.url_private_download);
|
||||
if (author === "bot") ownedBotFiles.add(fileId);
|
||||
if (++calls > 120)
|
||||
throw new Error(
|
||||
"Slack fixture exceeded its declared bounded provider calls",
|
||||
);
|
||||
const response = await fetcher(destination, {
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(20000),
|
||||
headers: {
|
||||
authorization: `Bearer ${selectedExternalCredential(ctx, binding, "botTokenEnv")}`,
|
||||
},
|
||||
});
|
||||
receipts.push({
|
||||
kind: "bot",
|
||||
method: "private-file-download",
|
||||
ok: response.ok,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(
|
||||
"Slack actual file bytes unavailable; private response withheld",
|
||||
);
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Slack file response is empty");
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
size += part.value.length;
|
||||
if (size > maximumBytes || size > file.size)
|
||||
throw new Error("Slack file stream exceeds registered size");
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
if (size !== file.size)
|
||||
throw new Error("Slack file stream differs from registered size");
|
||||
return {
|
||||
id: file.id,
|
||||
byteSize: size,
|
||||
contentType: file.mimetype ?? "",
|
||||
filename: file.name ?? "",
|
||||
messageId: messages[0]!.ts,
|
||||
permalink: permalink.href,
|
||||
bytes: Buffer.concat(chunks),
|
||||
};
|
||||
},
|
||||
async call<T extends Record<string, unknown>>(
|
||||
kind: keyof typeof methods,
|
||||
method: string,
|
||||
parameters: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
assertSlackCall(binding, kind, method, parameters);
|
||||
const result = await invoke<T>(kind, method, parameters, signal);
|
||||
if (
|
||||
method === "chat.postMessage" &&
|
||||
kind !== "configuration" &&
|
||||
result.channel === binding.prerequisites.channelId &&
|
||||
typeof result.ts === "string"
|
||||
) {
|
||||
ownedMessages.push({ kind, ts: result.ts });
|
||||
ownedThreads.add(
|
||||
typeof parameters.thread_ts === "string"
|
||||
? parameters.thread_ts
|
||||
: result.ts,
|
||||
);
|
||||
}
|
||||
if (
|
||||
method === "files.completeUploadExternal" &&
|
||||
kind === "sender" &&
|
||||
Array.isArray(result.files)
|
||||
)
|
||||
for (const file of result.files) {
|
||||
if (file && typeof file.id === "string") ownedFiles.push(file.id);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
/** No caller-supplied deletion IDs: only receipts from this client's actual
|
||||
* successful creation calls may be removed. Existing channel content is never
|
||||
* enumerated for cleanup or adopted as owned. */
|
||||
async cleanupOwned() {
|
||||
const failures: string[] = [];
|
||||
// Product-published replies are owned only through this client's genuinely
|
||||
// created roots. Read each exact thread, then delete only this app's bot
|
||||
// messages; never adopt another author's content as disposable.
|
||||
const threads = [...ownedThreads];
|
||||
ownedThreads.clear();
|
||||
for (const ts of threads)
|
||||
try {
|
||||
const thread = await invoke<{
|
||||
ok: true;
|
||||
messages: Array<{ ts: string; thread_ts?: string; user?: string }>;
|
||||
has_more?: boolean;
|
||||
}>("bot", "conversations.replies", {
|
||||
channel: binding.prerequisites.channelId,
|
||||
ts,
|
||||
limit: 100,
|
||||
});
|
||||
if (thread.has_more)
|
||||
throw new Error("Owned Slack cleanup exceeds finite thread bound");
|
||||
for (const message of thread.messages ?? [])
|
||||
if (
|
||||
message.user === binding.prerequisites.botUserId &&
|
||||
message.thread_ts === ts
|
||||
)
|
||||
await invoke("bot", "chat.delete", {
|
||||
channel: binding.prerequisites.channelId,
|
||||
ts: message.ts,
|
||||
});
|
||||
} catch {
|
||||
failures.push("owned-thread-replies");
|
||||
}
|
||||
for (const file of ownedBotFiles)
|
||||
try {
|
||||
await invoke("bot", "files.delete", { file });
|
||||
} catch {
|
||||
failures.push("owned-bot-file");
|
||||
}
|
||||
ownedBotFiles.clear();
|
||||
for (const file of [...new Set(ownedFiles.splice(0))].reverse())
|
||||
try {
|
||||
await invoke("sender", "files.delete", { file });
|
||||
} catch {
|
||||
failures.push("owned-file");
|
||||
}
|
||||
for (const message of ownedMessages.splice(0).reverse())
|
||||
try {
|
||||
await invoke(message.kind, "chat.delete", {
|
||||
channel: binding.prerequisites.channelId,
|
||||
ts: message.ts,
|
||||
});
|
||||
} catch {
|
||||
failures.push("owned-message");
|
||||
}
|
||||
if (failures.length)
|
||||
throw new Error(
|
||||
`Slack disposable cleanup incomplete: ${failures.length} owned resources could not be removed`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface SlackIngressReceipt {providerCalls:number;query:string;endpointId:string;conversationId:string;issueId:string;humanCommentId:string;linkedPaperclipUserId:string;provider:{workspaceId:string;channelId:string;threadId:string;canonicalMessageId:string;senderUserId:string}}
|
||||
export interface SlackIngressReceipt {
|
||||
providerCalls: number;
|
||||
query: string;
|
||||
endpointId: string;
|
||||
conversationId: string;
|
||||
issueId: string;
|
||||
humanCommentId: string;
|
||||
linkedPaperclipUserId: string;
|
||||
provider: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
threadId: string;
|
||||
canonicalMessageId: string;
|
||||
senderUserId: string;
|
||||
};
|
||||
}
|
||||
/** Source-qualified public DTO checks; externalThreadId is the SDK encoded key. */
|
||||
export function slackIngressMatches(input:{receipt:SlackIngressReceipt;endpoint:ChatEndpoint;conversation:ChatConversation;binding:ExternalChannelBindingSummary;comment:{id:string;body:string;authorUserId:string|null;metadata?:{sourceChannel?:string}};canonicalQuery:string;companyId:string;agentId:string}){
|
||||
const {receipt:r,endpoint:e,conversation:c,binding:b,comment}=input;
|
||||
return Number.isSafeInteger(r.providerCalls)&&r.providerCalls>=1&&r.providerCalls<=20&&r.query===input.canonicalQuery&&e.id===r.endpointId&&e.companyId===input.companyId&&e.assignedAgentId===input.agentId&&e.provider==="slack"&&e.providerAccountId===r.provider.workspaceId
|
||||
&&c.id===r.conversationId&&c.endpointId===e.id&&c.companyId===input.companyId&&c.issueId===r.issueId&&c.externalThreadId===`slack:${r.provider.channelId}:${r.provider.threadId}`
|
||||
&&c.externalConversationId===`slack:${r.provider.channelId}`
|
||||
&&b.endpointId===e.id&&b.conversationId===c.id&&b.provider==="slack"&&b.assignedAgentLocked===true
|
||||
&&comment.id===r.humanCommentId&&comment.body===input.canonicalQuery&&comment.authorUserId===r.linkedPaperclipUserId&&comment.metadata?.sourceChannel==="slack"
|
||||
&&/^\d+\.\d{6}$/.test(r.provider.threadId)&&/^\d+\.\d{6}$/.test(r.provider.canonicalMessageId)&&r.provider.senderUserId!==e.botExternalId;
|
||||
export function slackIngressMatches(input: {
|
||||
receipt: SlackIngressReceipt;
|
||||
endpoint: ChatEndpoint;
|
||||
conversation: ChatConversation;
|
||||
binding: ExternalChannelBindingSummary;
|
||||
comment: {
|
||||
id: string;
|
||||
body: string;
|
||||
authorUserId: string | null;
|
||||
metadata?: { sourceChannel?: string };
|
||||
};
|
||||
canonicalQuery: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
}) {
|
||||
const {
|
||||
receipt: r,
|
||||
endpoint: e,
|
||||
conversation: c,
|
||||
binding: b,
|
||||
comment,
|
||||
} = input;
|
||||
return (
|
||||
Number.isSafeInteger(r.providerCalls) &&
|
||||
r.providerCalls >= 1 &&
|
||||
r.providerCalls <= 20 &&
|
||||
r.query === input.canonicalQuery &&
|
||||
e.id === r.endpointId &&
|
||||
e.companyId === input.companyId &&
|
||||
e.assignedAgentId === input.agentId &&
|
||||
e.provider === "slack" &&
|
||||
e.providerAccountId === r.provider.workspaceId &&
|
||||
c.id === r.conversationId &&
|
||||
c.endpointId === e.id &&
|
||||
c.companyId === input.companyId &&
|
||||
c.issueId === r.issueId &&
|
||||
c.externalThreadId ===
|
||||
`slack:${r.provider.channelId}:${r.provider.threadId}` &&
|
||||
c.externalConversationId === `slack:${r.provider.channelId}` &&
|
||||
b.endpointId === e.id &&
|
||||
b.conversationId === c.id &&
|
||||
b.provider === "slack" &&
|
||||
b.assignedAgentLocked === true &&
|
||||
comment.id === r.humanCommentId &&
|
||||
comment.body === input.canonicalQuery &&
|
||||
comment.authorUserId === r.linkedPaperclipUserId &&
|
||||
comment.metadata?.sourceChannel === "slack" &&
|
||||
/^\d+\.\d{6}$/.test(r.provider.threadId) &&
|
||||
/^\d+\.\d{6}$/.test(r.provider.canonicalMessageId) &&
|
||||
r.provider.senderUserId !== e.botExternalId
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user