mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
fix: qualify Slack transport and task tool initialization
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
985e0ea223
commit
4e00210da0
11 files changed
+119
-25
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user