fix: qualify Slack transport and task tool initialization

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-22 12:46:05 -05:00
1 parent 985e0ea223
commit 4e00210da0
11 files changed
+119 -25

No files matched your search

+8
View File
@@ -38168,6 +38168,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
)).limit(1);
if (!action || action.payload.requestedByActorType !== "user" || action.payload.requestedByActorId !== run.responsibleUserId)
throw forbidden("Slack tools require a verified linked Slack request");
// This standalone resolver does not own the scheduler's issue lock. Use
// the ingress lock order (endpoint, then issue) so a normal webhook or
// endpoint update cannot turn tool setup into a NOWAIT failure. The
// scheduler retains its nonblocking check when it owns the issue first.
await tx.select({ id: chatEndpoints.id }).from(chatEndpoints).where(and(
eq(chatEndpoints.companyId, binding.companyId),
eq(chatEndpoints.id, action.endpointId),
)).for("no key update");
const source = await authorizeInboundWakeup(tx, action);
if (source.endpoint.provider !== "slack") throw forbidden("This is not a Slack task");
const [principal] = await tx.select().from(chatExternalPrincipals).where(and(
+11 -1
View File
@@ -216,7 +216,17 @@ export async function prepareConnectorSkillDelivery(
adapterType === "paperclip_runner" ||
(config.engine === "cli" &&
["codex_local", "claude_local", "kimi_local"].includes(adapterType));
if (scopedFiles) return { config, instructions: "" };
if (scopedFiles) {
// Runner models with semantic tools cannot necessarily read staged skill
// files. Supply the Slack contract and verified source IDs in their input;
// keep the staged bundle for CLI-capable engines and compatibility hashing.
const slack = adapterType === "paperclip_runner"
? config.paperclipRuntimeSkills.filter(entry => entry.key === "paperclipai/paperclip/slack")
: [];
const instructions = (await Promise.all(slack.map(entry =>
fs.readFile(path.join(entry.source, "SKILL.md"), "utf8")))).join("\n\n");
return { config, instructions };
}
const assigned = config.paperclipRuntimeSkills.filter((entry) =>
isConnectorSkill(entry.key),
);
@@ -171,6 +171,18 @@ describe("Slack contracts and transport", () => {
).rejects.toThrow("Unsupported");
expect(fetcher).not.toHaveBeenCalled();
});
it("encodes Slack arguments like the official SDK, with nested values as JSON fields", async () => {
const fetcher = vi.fn().mockImplementation(async () => Response.json({ ok: true }));
await slackClient("bot-secret", fetcher)("conversations.list", {
types: "public_channel,private_channel,mpim,im", limit: 50, exclude_archived: true, cursor: undefined,
});
expect(fetcher.mock.calls[0][1].headers["content-type"]).toContain("application/x-www-form-urlencoded");
expect(Object.fromEntries(new URLSearchParams(fetcher.mock.calls[0][1].body))).toEqual({
types: "public_channel,private_channel,mpim,im", limit: "50", exclude_archived: "true",
});
await slackClient("bot-secret", fetcher)("files.completeUploadExternal", { files: [{ id: "F123", title: "Test & review" }] });
expect(JSON.parse(new URLSearchParams(fetcher.mock.calls[1][1].body).get("files")!)).toEqual([{ id: "F123", title: "Test & review" }]);
});
it("reports missing scopes without exposing raw provider errors or credentials", async () => {
const fetcher = vi.fn().mockResolvedValue(
new Response(
@@ -39,9 +39,14 @@ export function slackClient(token: string, fetchImpl = fetch) {
signal: AbortSignal.timeout(20_000),
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json; charset=utf-8",
"content-type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: JSON.stringify(args),
// Match Slack's official WebClient encoding, including nested JSON
// arguments. Some Web API methods reject a JSON request body.
body: new URLSearchParams(Object.entries(args)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => [key, typeof value === "object"
? JSON.stringify(value) : String(value)])).toString(),
});
} catch {
throw unprocessable(
@@ -104,7 +109,7 @@ export function slackClient(token: string, fetchImpl = fetch) {
? result.needed
: undefined;
throw unprocessable(
`Slack could not perform this operation: ${code}${scopes ? `. Reinstall the app with ${scopes}.` : ". Check app permissions and Slack feature availability."}`,
`Slack could not perform this operation: ${code}${scopes ? `. Reinstall the app with ${scopes}.` : code === "invalid_arguments" ? ". Check the tool arguments; installing another connector will not fix this request." : ". Check app permissions and Slack feature availability."}`,
{ code: `slack_${code}`, missingScopes: scopes },
);
}
@@ -62,11 +62,10 @@ describe("Slack transient search provider (not exposed to retaining runtimes)",
const init = (
fetched.mock.calls as unknown as [unknown, RequestInit][]
)[0][1];
expect(JSON.parse(String(init.body))).toMatchObject({
action_token: "verified-event-token",
term_clauses: ["in:<#C1>", "from:<@U1>"],
include_context_messages: false,
});
const sent = new URLSearchParams(String(init.body));
expect(sent.get("action_token")).toBe("verified-event-token");
expect(JSON.parse(sent.get("term_clauses")!)).toEqual(["in:<#C1>", "from:<@U1>"]);
expect(sent.get("include_context_messages")).toBe("false");
expect(JSON.stringify(result)).not.toContain("verified-event-token");
});
it("uses a search-only personal grant for private search and rejects revoked access before delivery", async () => {
@@ -97,7 +96,7 @@ describe("Slack transient search provider (not exposed to retaining runtimes)",
expect(init.headers).toMatchObject({
authorization: "Bearer personal-token",
});
expect(JSON.parse(String(init.body))).not.toHaveProperty("action_token");
expect(new URLSearchParams(String(init.body)).has("action_token")).toBe(false);
});
it("does not expose file hits outside bot-verified channel shares", async () => {
const fetched = vi.fn(async (url: RequestInfo | URL) =>
@@ -1,3 +1,4 @@
import { slackSearchConfigSchema } from "@paperclipai/shared";
import { SLACK_NATIVE_SEARCH_LIMITATION } from "./slack-native-search.js";
import { syncConnectionCredentialBindings } from "../connection-credential-bindings.js";
import { randomBytes } from "node:crypto";
@@ -29,12 +30,6 @@ export const SLACK_SEARCH_USER_SCOPES = [
"search:read.private",
"search:read.files",
];
export const slackSearchConfigSchema = z
.object({
clientId: z.string().regex(/^\d+\.\d+$/),
clientSecret: z.string().min(10).max(512),
})
.strict();
const configSchema = z.object({
clientId: z.string(),
clientSecretId: z.string().uuid(),