From 5a407a7e91b245f6f32a1ee5ca40b5b5cc3b905e Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Thu, 30 Jul 2026 15:25:49 -0400 Subject: [PATCH] slack file + emoji powers: download the original, send a file back, set custom emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bevelina's own asks from the :anya: thread — she could edit the image but not reach the original attachment, post the result, or land the emoji herself. - tools/slack.ts: the slack registry moves out of main.ts (testable, one place) and grows download_file (original bytes into /files; bot token only ever rides to files.slack.com), upload_file (reserve→put→complete flow, addressed like reply, workspace-paths only), and emoji_set (admin.emoji.add, replace-on-exists; outward, so it waits for a go-ahead; fails room-safe when SLACK_ADMIN_TOKEN is absent). - inbox/service: attachment lines now carry mimetype + url_private so the original file is addressable from the turn, not just named. - policy.example.yaml documents the grants; KNOWN_TOOLS derives from SLACK_TOOL_NAMES. Co-Authored-By: Claude Fable 5 --- deploy/policy.example.yaml | 4 + src/ledger/inbox.ts | 6 +- src/main.ts | 62 ++++-------- src/service.ts | 6 +- src/tools/slack.ts | 200 +++++++++++++++++++++++++++++++++++++ test/slack-tools.test.ts | 153 ++++++++++++++++++++++++++++ 6 files changed, 384 insertions(+), 47 deletions(-) create mode 100644 src/tools/slack.ts create mode 100644 test/slack-tools.test.ts diff --git a/deploy/policy.example.yaml b/deploy/policy.example.yaml index 9d6b73f..0ea6c66 100644 --- a/deploy/policy.example.yaml +++ b/deploy/policy.example.yaml @@ -52,6 +52,10 @@ identities: monthly_cap: 200 per_task_cap: 25 grants: [] # external tools this identity may use (built-ins are always available) + # slack registry: read_channel, read_thread, download_file, upload_file, emoji_set + # (emoji_set is consequential and needs SLACK_ADMIN_TOKEN in the daemon's env), e.g.: + # - tool: download_file + # preauthorized_action_classes: [] ambient: enabled_venues: [] # venues where speak-only ambient behavior is on (default: off, §9) tick_interval_ms: 1800000 diff --git a/src/ledger/inbox.ts b/src/ledger/inbox.ts index c096f84..8c3c81c 100644 --- a/src/ledger/inbox.ts +++ b/src/ledger/inbox.ts @@ -19,7 +19,9 @@ export interface InboxMessage { // How an addressed message reached her (router.ts writes it into the payload): a direct // address (mention/dm) wakes the mind immediately; thread_follow is the ear's to judge. addressMode?: "mention" | "dm" | "thread_follow"; - files?: { name: string }[]; + // Attachment metadata as the router recorded it. urlPrivate is how a turn addresses the + // original file (download_file) — older events carry name only. + files?: { name: string; mimetype?: string; urlPrivate?: string; size?: number }[]; } export function pendingMessages(db: Database, identityId: string, limit = 200): InboxMessage[] { @@ -40,7 +42,7 @@ export function messagesAfter(db: Database, identityId: string, afterRowid: numb ) .all(identityId, cursor, limit) as { rowid: number; id: string; kind: InboxMessage["kind"]; venue_id: string | null; thread_root_id: string | null; principal_id: string | null; payload: string; received_at: string }[]; return rows.map((r) => { - const p = JSON.parse(r.payload) as { text?: string; ts?: string; addressMode?: InboxMessage["addressMode"]; files?: { name: string }[] }; + const p = JSON.parse(r.payload) as { text?: string; ts?: string; addressMode?: InboxMessage["addressMode"]; files?: InboxMessage["files"] }; return { rowid: r.rowid, id: r.id, diff --git a/src/main.ts b/src/main.ts index 690f601..74aeb2f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,8 @@ // lives in tested library modules; this file only assembles them and owns the process lifecycle // (env resolution, SIGTERM/SIGINT, the db handle). import { mkdirSync } from "node:fs"; -import { INTEGRATION_REGISTRIES, flattenRegistries, type ToolRegistry } from "./tools/catalog"; +import { INTEGRATION_REGISTRIES, flattenRegistries } from "./tools/catalog"; +import { slackRegistry, SLACK_TOOL_NAMES } from "./tools/slack"; import { homedir } from "node:os"; import { join } from "node:path"; import { openLedger } from "./ledger/db"; @@ -33,6 +34,7 @@ config (env): SLACK_BOT_TOKEN xoxb-... (required for start) SLACK_APP_TOKEN xapp-... (Socket Mode) (required for start) SLACK_BOT_USER_ID U... (required for start) + SLACK_ADMIN_TOKEN xoxp-... (admin user) (optional: enables emoji_set) `; const dbPath = () => process.env.EARSHOT_DB ?? "./earshot.db"; @@ -40,10 +42,10 @@ const policyPath = () => process.env.EARSHOT_POLICY ?? "./policy.yaml"; // External tools an identity may be granted must be known to policy validation. The built-in // toolset (task_*, memory_*, reply, set_wake) is never "granted" (SPEC §11); audit_query is the -// one built-in that IS grant-gated (§15). read_channel/read_thread are named here as literals -// (their registry lives in cmdStart, closed over the live adapter) because validate/status run +// one built-in that IS grant-gated (§15). The slack tool names come from tools/slack.ts (its +// registry is built in cmdStart, closed over the live adapter) because validate/status run // makeStore with no adapter; the integration names derive from the registries. -const KNOWN_TOOLS = new Set(["audit_query", "read_channel", "read_thread", ...INTEGRATION_REGISTRIES.flatMap((r) => Object.keys(r.tools))]); +const KNOWN_TOOLS = new Set(["audit_query", ...SLACK_TOOL_NAMES, ...INTEGRATION_REGISTRIES.flatMap((r) => Object.keys(r.tools))]); function makeStore(): PolicyStore { return new PolicyStore(fileSource(policyPath()), { knownTools: KNOWN_TOOLS }); @@ -85,48 +87,20 @@ async function cmdStart(): Promise { const adapter = new SlackAdapter({ botToken, appToken, botUserId }, (line) => log.info("slack", { line })); // External tools an identity can be granted (KNOWN_TOOLS gates policy validation). The slack - // registry needs the live adapter, so it's assembled here rather than in the static catalog. - // read_channel lets the agent pull another channel's recent history on demand ("summarize - // #bug-reports"). No action classes → a plain read, allowed without confirmation. - const slackRegistry: ToolRegistry = { - name: "slack", - skill: - "Beyond the thread in front of you: pull a channel's recent history on demand, then open any conversation it roots. " + - "Reach for these when someone points you at a channel or you need the surrounding discussion, not just what you overheard.", - tools: { - read_channel: { - run: async (args: unknown) => { - const a = (args ?? {}) as { channel?: string; limit?: number }; - if (!a.channel) return { success: false, output: "read_channel needs a { channel } — mention it as #channel so its id resolves" }; - try { - const msgs = await adapter.readHistory(a.channel, Math.min(a.limit ?? 20, 100)); - return { success: true, output: JSON.stringify(msgs) }; - } catch (e) { - return { success: false, output: e instanceof Error ? e.message : String(e) }; - } - }, - description: "Read recent messages from a Slack channel (with permalinks for citing). Only channel-root messages — a message with reply_count > 0 roots a thread; pull its replies with read_thread. Input: { channel, limit? } — channel as <#C…> link or id.", - inputSchema: { type: "object", additionalProperties: false, required: ["channel"], properties: { channel: { type: "string" }, limit: { type: "number" } } }, - }, - read_thread: { - run: async (args: unknown) => { - const a = (args ?? {}) as { channel?: string; thread_ts?: string; limit?: number }; - if (!a.channel || !a.thread_ts) return { success: false, output: "read_thread needs { channel, thread_ts } — thread_ts is the root message's ts from read_channel" }; - try { - const msgs = await adapter.readThread(a.channel, a.thread_ts, Math.min(a.limit ?? 50, 200)); - return { success: true, output: JSON.stringify(msgs) }; - } catch (e) { - return { success: false, output: e instanceof Error ? e.message : String(e) }; - } - }, - description: "Read a Slack thread's replies (with permalinks for citing). Input: { channel, thread_ts, limit? } — thread_ts is the root message's ts, as returned by read_channel.", - inputSchema: { type: "object", additionalProperties: false, required: ["channel", "thread_ts"], properties: { channel: { type: "string" }, thread_ts: { type: "string" }, limit: { type: "number" } } }, - }, - }, - }; + // registry (tools/slack.ts) needs the live adapter and the daemon's Slack credentials, so it's + // assembled here rather than in the static catalog. SLACK_ADMIN_TOKEN (a user token with admin + // scope) is optional — without it emoji_set fails friendly, everything else works. + const slack = slackRegistry({ + readHistory: (channel, limit) => adapter.readHistory(channel, limit), + readThread: (channel, threadTs, limit) => adapter.readThread(channel, threadTs, limit), + downloadFile: (url) => adapter.downloadFile(url), + botToken, + adminToken: process.env.SLACK_ADMIN_TOKEN, + workspace, + }); // Linear / GitHub / Notion (kit transports at read/write grain) + the adapter-backed slack // registry. ONE list: the broker catalog, KNOWN_TOOLS, and the toolbox digest all derive from it. - const registries = [...INTEGRATION_REGISTRIES, slackRegistry]; + const registries = [...INTEGRATION_REGISTRIES, slack]; const catalog = flattenRegistries(registries); let counter = 0; diff --git a/src/service.ts b/src/service.ts index 5a1bff4..877f221 100644 --- a/src/service.ts +++ b/src/service.ts @@ -48,7 +48,11 @@ const ATTENTION_PROMPT_CAP = 5; // A delivered inbox message, verbatim, with the coordinates she needs to reply into or react // to it: venue, thread root, and the message's own ts. function inboxLine(m: InboxMessage): string { - const files = m.files?.length ? ` [attached: ${m.files.map((f) => f.name).join(", ")}]` : ""; + // urlPrivate is the attachment's address for download_file — without it in the line, the + // original file (not a preview) is unreachable to the turn. + const files = m.files?.length + ? ` [attached: ${m.files.map((f) => `${f.name}${f.mimetype ? ` (${f.mimetype})` : ""}${f.urlPrivate ? ` url_private=${f.urlPrivate}` : ""}`).join(", ")}]` + : ""; return `[<#${m.venueId}>${m.threadRootId ? ` thread=${m.threadRootId}` : ""} ts=${m.ts}] <@${m.principalId ?? "?"}>: ${m.text.slice(0, 2500)}${files}`; } diff --git a/src/tools/slack.ts b/src/tools/slack.ts new file mode 100644 index 0000000..e8449d9 --- /dev/null +++ b/src/tools/slack.ts @@ -0,0 +1,200 @@ +// The slack registry: adapter-backed tools over the surface she already lives on. Assembled at +// runtime (unlike catalog.ts's static integrations) because every tool here closes over the live +// adapter and the daemon's Slack credentials. Reads (channel/thread history, file download) carry +// no action classes; emoji_set changes the whole workspace, so it is statically `outward` and +// rides the confirmation flow like any consequential external call. upload_file is speech — a +// file landing in a thread is as visible and self-correcting as a reply — so it is ungated, but +// it only sends files from inside her own workspace: the daemon's filesystem is not hers to post. +import { basename, resolve, sep } from "node:path"; +import { mkdirSync } from "node:fs"; +import type { ToolRegistry } from "./catalog"; + +export interface SlackToolDeps { + readHistory(channel: string, limit: number): Promise; + readThread(channel: string, threadTs: string, limit: number): Promise; + // Fetch a Slack-hosted file's bytes with the bot token (files:read). + downloadFile(urlPrivate: string): Promise; + botToken: string; + // A user token with admin scope (SLACK_ADMIN_TOKEN) — custom emoji live behind the admin API. + // Absent → emoji_set fails friendly, everything else works. + adminToken?: string; + workspace: string; // the codex workspace — downloads land in /files, uploads must come from inside it + fetch?: typeof fetch; // injectable for tests +} + +export const SLACK_TOOL_NAMES = ["read_channel", "read_thread", "download_file", "upload_file", "emoji_set"] as const; + +type SlackApiResponse = { ok: boolean; error?: string } & Record; + +// A filename safe to land in the files dir: its own basename, path metacharacters stripped. +function safeName(name: string): string { + const base = basename(name).replace(/[^\w.\- ]/g, "_").trim(); + return base || "file"; +} + +function insideWorkspace(workspace: string, path: string): boolean { + const root = resolve(workspace); + const target = resolve(root, path); + return target === root || target.startsWith(root + sep); +} + +export function slackRegistry(deps: SlackToolDeps): ToolRegistry { + const doFetch = deps.fetch ?? fetch; + const api = async (method: string, token: string, body: Record): Promise => { + const res = await doFetch(`https://slack.com/api/${method}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify(body), + }); + return (await res.json()) as SlackApiResponse; + }; + + return { + name: "slack", + skill: + "Beyond the thread in front of you: pull a channel's recent history on demand, then open any conversation it roots. " + + "Attachments come through at full resolution — download one into your workspace to look at or work on it, and send a file " + + "from your workspace back into a conversation when the result IS a file. Reach for these when someone points you at a " + + "channel, an image, or asks for something a plain message can't carry. Changing the workspace's custom emoji is " + + "consequential and waits for a go-ahead.", + examples: [ + { + when: "someone posts a screenshot and asks you to work with it", + tool: "download_file", + args: { url: "https://files.slack.com/files-pri/T0-F0ABC123/screenshot.png", name: "screenshot.png" }, + result: '{"path":"files/screenshot.png","bytes":48213,"mimetype":"image/png"} — the original file, full resolution, now in your workspace', + }, + { + when: "the result of your work is a file (an edited image, a generated doc)", + tool: "upload_file", + args: { path: "files/anya-cleaned.png", venueId: "", threadRootId: "", title: "cleaned up" }, + }, + { + when: "the room wants a new or updated custom emoji (needs a go-ahead)", + tool: "emoji_set", + args: { name: "anya", url: "https://files.slack.com/files-pri/T0-F0ABC123/anya-cleaned.png" }, + }, + ], + tools: { + read_channel: { + run: async (args: unknown) => { + const a = (args ?? {}) as { channel?: string; limit?: number }; + if (!a.channel) return { success: false, output: "read_channel needs a { channel } — mention it as #channel so its id resolves" }; + try { + const msgs = await deps.readHistory(a.channel, Math.min(a.limit ?? 20, 100)); + return { success: true, output: JSON.stringify(msgs) }; + } catch (e) { + return { success: false, output: e instanceof Error ? e.message : String(e) }; + } + }, + description: + "Read recent messages from a Slack channel (with permalinks for citing). Only channel-root messages — a message with reply_count > 0 roots a thread; pull its replies with read_thread. Input: { channel, limit? } — channel as <#C…> link or id.", + inputSchema: { type: "object", additionalProperties: false, required: ["channel"], properties: { channel: { type: "string" }, limit: { type: "number" } } }, + }, + read_thread: { + run: async (args: unknown) => { + const a = (args ?? {}) as { channel?: string; thread_ts?: string; limit?: number }; + if (!a.channel || !a.thread_ts) return { success: false, output: "read_thread needs { channel, thread_ts } — thread_ts is the root message's ts from read_channel" }; + try { + const msgs = await deps.readThread(a.channel, a.thread_ts, Math.min(a.limit ?? 50, 200)); + return { success: true, output: JSON.stringify(msgs) }; + } catch (e) { + return { success: false, output: e instanceof Error ? e.message : String(e) }; + } + }, + description: + "Read a Slack thread's replies (with permalinks for citing). Input: { channel, thread_ts, limit? } — thread_ts is the root message's ts, as returned by read_channel.", + inputSchema: { type: "object", additionalProperties: false, required: ["channel", "thread_ts"], properties: { channel: { type: "string" }, thread_ts: { type: "string" }, limit: { type: "number" } } }, + }, + download_file: { + run: async (args: unknown) => { + const a = (args ?? {}) as { url?: string; name?: string }; + if (!a.url) return { success: false, output: "download_file needs { url } — an attachment's url_private, from the message that carried it" }; + // The bot token rides the request as a bearer header — only Slack's file host may see it. + let host: string; + try { + host = new URL(a.url).host; + } catch { + return { success: false, output: "download_file: that isn't a URL" }; + } + if (host !== "files.slack.com") return { success: false, output: "download_file only fetches Slack-hosted attachments (files.slack.com url_private links)" }; + try { + const bytes = await deps.downloadFile(a.url); + const dir = resolve(deps.workspace, "files"); + mkdirSync(dir, { recursive: true }); + const name = safeName(a.name ?? new URL(a.url).pathname); + await Bun.write(resolve(dir, name), bytes); + return { success: true, output: JSON.stringify({ path: `files/${name}`, bytes: bytes.length }) }; + } catch (e) { + return { success: false, output: e instanceof Error ? e.message : String(e) }; + } + }, + description: + "Download a message attachment (image, doc — the original, full resolution) into your workspace. Input: { url, name? } — url is the attachment's url_private from its message line; name is what to save it as. Returns the workspace-relative path.", + inputSchema: { type: "object", additionalProperties: false, required: ["url"], properties: { url: { type: "string" }, name: { type: "string" } } }, + }, + upload_file: { + run: async (args: unknown) => { + const a = (args ?? {}) as { path?: string; venueId?: string; threadRootId?: string | null; title?: string }; + if (!a.path || !a.venueId) return { success: false, output: "upload_file needs { path, venueId } — path is workspace-relative; venueId is the conversation's <#…>" }; + if (!insideWorkspace(deps.workspace, a.path)) return { success: false, output: "upload_file only sends files from your own workspace" }; + try { + const file = Bun.file(resolve(deps.workspace, a.path)); + if (!(await file.exists())) return { success: false, output: `no such file in your workspace: ${a.path}` }; + const bytes = await file.bytes(); + const filename = basename(a.path); + // Slack's external upload flow: reserve a URL, POST the bytes, then complete into the venue. + const ticket = await api("files.getUploadURLExternal", deps.botToken, { filename, length: bytes.length }); + if (!ticket.ok || typeof ticket.upload_url !== "string" || typeof ticket.file_id !== "string") { + return { success: false, output: `upload failed: ${ticket.error ?? "no upload url"}${ticket.error === "missing_scope" ? " — the Slack app needs the files:write scope" : ""}` }; + } + const put = await doFetch(ticket.upload_url, { method: "POST", body: bytes }); + if (!put.ok) return { success: false, output: `upload failed: HTTP ${put.status} sending the file bytes` }; + const done = await api("files.completeUploadExternal", deps.botToken, { + files: [{ id: ticket.file_id, title: a.title ?? filename }], + channel_id: a.venueId, + ...(a.threadRootId ? { thread_ts: a.threadRootId } : {}), + }); + if (!done.ok) return { success: false, output: `upload failed: ${done.error}` }; + return { success: true, output: `sent ${filename} into <#${a.venueId}>${a.threadRootId ? ` thread=${a.threadRootId}` : ""}` }; + } catch (e) { + return { success: false, output: e instanceof Error ? e.message : String(e) }; + } + }, + description: + "Send a file from your workspace into a conversation — it lands as a message with the file attached. Input: { path, venueId, threadRootId?, title? } — path workspace-relative; venueId/threadRootId address it exactly like reply (threadRootId null or absent posts top-level).", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["path", "venueId"], + properties: { path: { type: "string" }, venueId: { type: "string" }, threadRootId: { type: ["string", "null"] }, title: { type: "string" } }, + }, + }, + emoji_set: { + actionClasses: () => ["outward"], + run: async (args: unknown) => { + const a = (args ?? {}) as { name?: string; url?: string }; + const name = a.name?.replace(/:/g, "").trim().toLowerCase(); + if (!name || !a.url) return { success: false, output: "emoji_set needs { name, url } — the emoji's name (no colons) and a URL of its image" }; + if (!deps.adminToken) return { success: false, output: "custom emoji aren't wired up here yet — an admin credential is missing; a workspace admin can add it by hand meanwhile" }; + try { + let result = await api("admin.emoji.add", deps.adminToken, { name, url: a.url }); + if (!result.ok && (result.error === "emoji_already_exists" || result.error === "error_name_taken")) { + // "update" = replace: remove the old image, then add the new one under the same name. + const removed = await api("admin.emoji.remove", deps.adminToken, { name }); + if (!removed.ok) return { success: false, output: `emoji_set: :${name}: exists and couldn't be replaced (${removed.error})` }; + result = await api("admin.emoji.add", deps.adminToken, { name, url: a.url }); + } + if (!result.ok) return { success: false, output: `emoji_set failed: ${result.error}` }; + return { success: true, output: `:${name}: is live` }; + } catch (e) { + return { success: false, output: e instanceof Error ? e.message : String(e) }; + } + }, + description: + "Create or replace a workspace custom emoji from an image URL. Input: { name, url } — name without colons; url must be a fetchable image (a Slack attachment's url_private works). Consequential — may wait for a go-ahead.", + inputSchema: { type: "object", additionalProperties: false, required: ["name", "url"], properties: { name: { type: "string" }, url: { type: "string" } } }, + }, + }, + }; +} diff --git a/test/slack-tools.test.ts b/test/slack-tools.test.ts new file mode 100644 index 0000000..44286e0 --- /dev/null +++ b/test/slack-tools.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { slackRegistry, SLACK_TOOL_NAMES, type SlackToolDeps } from "../src/tools/slack"; + +// A registry wired to fakes: no network, no Slack. `calls` records every Web API method hit so +// tests assert the exact wire conversation; `responses` scripts what Slack answers. +function makeRegistry(opts: { + responses?: Record; + downloaded?: Uint8Array; + adminToken?: string; +}) { + const workspace = mkdtempSync(join(tmpdir(), "earshot-slack-tools-")); + const calls: { url: string; body?: unknown }[] = []; + const responses = new Map(Object.entries(opts.responses ?? {})); + const fakeFetch = (async (url: unknown, init?: { body?: unknown }) => { + const u = String(url); + calls.push({ url: u, body: typeof init?.body === "string" ? JSON.parse(init.body) : init?.body }); + const method = u.startsWith("https://slack.com/api/") ? u.slice("https://slack.com/api/".length) : u; + const queued = responses.get(method); + const payload = queued?.shift() ?? { ok: true }; + return { ok: true, status: 200, json: async () => payload }; + }) as unknown as typeof fetch; + const deps: SlackToolDeps = { + readHistory: async () => [{ text: "root" }], + readThread: async () => [{ text: "reply" }], + downloadFile: async () => opts.downloaded ?? new Uint8Array([1, 2, 3]), + botToken: "xoxb-test", + ...(opts.adminToken ? { adminToken: opts.adminToken } : {}), + workspace, + fetch: fakeFetch, + }; + return { registry: slackRegistry(deps), workspace, calls }; +} + +describe("slack registry shape", () => { + test("SLACK_TOOL_NAMES matches the registry's tools exactly (KNOWN_TOOLS derives from it)", () => { + const { registry } = makeRegistry({}); + expect(Object.keys(registry.tools).sort()).toEqual([...SLACK_TOOL_NAMES].sort()); + }); + + test("every example names a tool in the registry", () => { + const { registry } = makeRegistry({}); + for (const ex of registry.examples ?? []) expect(Object.keys(registry.tools)).toContain(ex.tool); + }); + + test("only emoji_set is consequential — reads and in-room speech are ungated", () => { + const { registry } = makeRegistry({}); + for (const [name, spec] of Object.entries(registry.tools)) { + const classes = spec.actionClasses?.({}) ?? []; + expect(classes).toEqual(name === "emoji_set" ? ["outward"] : []); + } + }); +}); + +describe("download_file", () => { + test("saves the original bytes into the workspace files dir and returns the relative path", async () => { + const bytes = new Uint8Array([7, 7, 7, 7]); + const { registry, workspace } = makeRegistry({ downloaded: bytes }); + const result = await registry.tools.download_file!.run!({ url: "https://files.slack.com/files-pri/T0-F1/pic.png", name: "pic.png" }); + expect(result.success).toBe(true); + expect(JSON.parse(result.output)).toEqual({ path: "files/pic.png", bytes: 4 }); + expect(new Uint8Array(await Bun.file(join(workspace, "files", "pic.png")).arrayBuffer())).toEqual(bytes); + }); + + test("refuses a non-Slack host — the bot token must never ride to an arbitrary URL", async () => { + const { registry } = makeRegistry({}); + const result = await registry.tools.download_file!.run!({ url: "https://evil.example.com/steal" }); + expect(result.success).toBe(false); + expect(result.output).toContain("files.slack.com"); + }); + + test("strips path traversal from the requested save name", async () => { + const { registry } = makeRegistry({}); + const result = await registry.tools.download_file!.run!({ url: "https://files.slack.com/f/x.png", name: "../../etc/passwd" }); + expect(result.success).toBe(true); + expect(JSON.parse(result.output).path).toBe("files/passwd"); + }); +}); + +describe("upload_file", () => { + test("runs Slack's reserve → put → complete flow, threading the file into the addressed conversation", async () => { + const { registry, workspace, calls } = makeRegistry({ + responses: { + "files.getUploadURLExternal": [{ ok: true, upload_url: "https://upload.slack.example/u1", file_id: "F123" }], + "files.completeUploadExternal": [{ ok: true }], + }, + }); + writeFileSync(join(workspace, "out.png"), "png-bytes"); + const result = await registry.tools.upload_file!.run!({ path: "out.png", venueId: "C9", threadRootId: "17.001", title: "cleaned" }); + expect(result.success).toBe(true); + expect(result.output).toContain("<#C9>"); + const complete = calls.find((c) => c.url.endsWith("files.completeUploadExternal"))!.body as Record; + expect(complete.channel_id).toBe("C9"); + expect(complete.thread_ts).toBe("17.001"); + expect(complete.files).toEqual([{ id: "F123", title: "cleaned" }]); + expect(calls.some((c) => c.url === "https://upload.slack.example/u1")).toBe(true); + }); + + test("refuses a path outside the workspace — the daemon's filesystem is not hers to post", async () => { + const { registry } = makeRegistry({}); + const result = await registry.tools.upload_file!.run!({ path: "../../../etc/passwd", venueId: "C9" }); + expect(result.success).toBe(false); + expect(result.output).toContain("workspace"); + }); + + test("a missing file fails friendly with the path named", async () => { + const { registry } = makeRegistry({}); + const result = await registry.tools.upload_file!.run!({ path: "nope.png", venueId: "C9" }); + expect(result.success).toBe(false); + expect(result.output).toContain("nope.png"); + }); + + test("surfaces a missing files:write scope by name", async () => { + const { registry, workspace } = makeRegistry({ + responses: { "files.getUploadURLExternal": [{ ok: false, error: "missing_scope" }] }, + }); + writeFileSync(join(workspace, "out.png"), "x"); + const result = await registry.tools.upload_file!.run!({ path: "out.png", venueId: "C9" }); + expect(result.success).toBe(false); + expect(result.output).toContain("files:write"); + }); +}); + +describe("emoji_set", () => { + test("without an admin credential it fails in room-safe language (no env vars, no scopes)", async () => { + const { registry } = makeRegistry({}); + const result = await registry.tools.emoji_set!.run!({ name: "anya", url: "https://files.slack.com/f/a.png" }); + expect(result.success).toBe(false); + expect(result.output).not.toMatch(/SLACK_|token|scope/i); + }); + + test("adds the emoji with the admin token, normalizing the name", async () => { + const { registry, calls } = makeRegistry({ adminToken: "xoxp-admin", responses: { "admin.emoji.add": [{ ok: true }] } }); + const result = await registry.tools.emoji_set!.run!({ name: ":Anya:", url: "https://files.slack.com/f/a.png" }); + expect(result.success).toBe(true); + expect((calls[0]!.body as Record).name).toBe("anya"); + }); + + test("an existing emoji is replaced: remove then re-add under the same name", async () => { + const { registry, calls } = makeRegistry({ + adminToken: "xoxp-admin", + responses: { + "admin.emoji.add": [{ ok: false, error: "emoji_already_exists" }, { ok: true }], + "admin.emoji.remove": [{ ok: true }], + }, + }); + const result = await registry.tools.emoji_set!.run!({ name: "anya", url: "https://files.slack.com/f/a.png" }); + expect(result.success).toBe(true); + expect(calls.map((c) => c.url.split("/api/")[1])).toEqual(["admin.emoji.add", "admin.emoji.remove", "admin.emoji.add"]); + }); +});