From 4a6c4d4a54b57f07c54430e17bc736ffcd97f99d Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Fri, 13 Nov 2026 16:18:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Slack=20frontend=20(S1)=20=E2=80=94?= =?UTF-8?q?=20talk=20to=20the=20conductor=20from=20a=20Slack=20DM,=20appro?= =?UTF-8?q?ve=20from=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Socket Mode client (no public URL, no new dependencies) - /codeoid auth : each allowlisted Slack user signs in with their own ZeroID key; a slash command isn't posted, so the key doesn't stay in Slack - a DM goes to the conductor (made on first use with the user's own token) or an attached session; replies come back to the DM - approval cards with Approve/Deny, naming the workstream, risk and sandbox; only the user a card was sent to can press it - a DM when another of your sessions waits on you (SessionManager.onNeedsOwner) - docs/slack.md: setup with an app manifest, and use Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- README.md | 2 +- docs/slack.md | 66 ++++ src/cli.ts | 20 ++ src/daemon/session-manager.ts | 29 ++ src/frontends/slack/index.ts | 532 +++++++++++++++++++++++++++++++ src/frontends/slack/socket.ts | 184 +++++++++++ src/tests/slack-frontend.test.ts | 207 ++++++++++++ src/tests/slack-socket.test.ts | 61 ++++ 9 files changed, 1103 insertions(+), 2 deletions(-) create mode 100644 docs/slack.md create mode 100644 src/frontends/slack/index.ts create mode 100644 src/frontends/slack/socket.ts create mode 100644 src/tests/slack-frontend.test.ts create mode 100644 src/tests/slack-socket.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index eecdb848..e8023390 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ codeoid start │ ├── TranscriptStore — JSONL persistence, survives daemon restart │ └── AgentIdentityManager — ZeroID identities for agents + sub-agents ├── Web UI frontend (SolidJS, served at /ui, also Telegram Mini App) - └── Telegram frontend (embedded, direct SessionManager access) + ├── Telegram frontend (embedded, direct SessionManager access) + └── Slack frontend (embedded, Socket Mode: src/frontends/slack/, docs/slack.md) ``` ### Plugin Architecture @@ -604,6 +605,7 @@ ZEROID_URL=highflame # issuer: preset (highflame | highflame-dev ZEROID_ISSUER= # override the expected `iss` claim (default: resolved ZEROID_URL) TELEGRAM_BOT_TOKEN=... # Optional: enables Telegram frontend TELEGRAM_ALLOWED_USER_IDS=123,456 # Required with bot token +SLACK_APP_TOKEN=xapp-... # Optional, with SLACK_BOT_TOKEN and SLACK_ALLOWED_USER_IDS: the Slack frontend (docs/slack.md) CODEOID_MCP_CONTROL_ENABLED=false # mount /mcp/control (docs/mcp-control.md) CODEOID_MCP_CONTROL_ACTIONS=false # also allow create / send / interrupt / destroy there CODEOID_ROUTINES_ENABLED=true # scheduler + routine commands (docs/routines.md) diff --git a/README.md b/README.md index c022dc46..70aecfac 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,7 @@ Full detail — keybindings, slash commands, ranking weights, rotation threshold ### Interfaces -One daemon, one source of truth, three ways in: the [terminal cockpit](docs/FEATURES.md#terminal-client) (shown at the top), a browser, and Telegram. +One daemon, one source of truth, four ways in: the [terminal cockpit](docs/FEATURES.md#terminal-client) (shown at the top), a browser, Telegram, and [Slack](docs/slack.md). [Device handoff](docs/FEATURES.md#device-handoff) lets you start on one and pick the session up on another — scrollback replays.

diff --git a/docs/slack.md b/docs/slack.md new file mode 100644 index 00000000..9a596cfe --- /dev/null +++ b/docs/slack.md @@ -0,0 +1,66 @@ +# Slack + +Talk to codeoid's conductor from a Slack DM, and approve what your sessions ask for from there. + +The daemon connects to Slack itself (Socket Mode), so it needs no public URL. +Slack works only with ZeroID sign-in, never in local mode. + +## Setting it up + +1. Create a Slack app in a workspace you own: → Create New App → From an app manifest, and paste: + + ```yaml + display_information: + name: codeoid + features: + bot_user: + display_name: codeoid + always_online: true + app_home: + messages_tab_enabled: true + messages_tab_read_only_enabled: false + slash_commands: + - command: /codeoid + description: Sign in, list and switch sessions + usage_hint: auth | ls | attach | conductor | stop | who + oauth_config: + scopes: + bot: + - chat:write + - commands + - im:history + - im:read + settings: + event_subscriptions: + bot_events: + - message.im + interactivity: + is_enabled: true + socket_mode_enabled: true + ``` + +2. Install it to the workspace, and copy the **Bot User OAuth Token** (`xoxb-…`). +3. Under Basic Information → App-Level Tokens, create one with the `connections:write` scope, and copy it (`xapp-…`). +4. Find your Slack member ID: your profile → ⋯ → Copy member ID. +5. Add to `~/.codeoid/.env`, then restart the daemon: + + ```bash + SLACK_APP_TOKEN=xapp-… + SLACK_BOT_TOKEN=xoxb-… + SLACK_ALLOWED_USER_IDS=U0123ABCD # comma-separated + ``` + +6. In Slack, run `/codeoid auth ` once with your ZeroID key. A slash command isn't posted to the conversation, so the key doesn't stay in it. + +`codeoid start --no-slack` starts the daemon without it. + +## Using it + +- Message the codeoid app to talk to the conductor. It is created the first time you write. +- `/codeoid ls` lists your sessions; `/codeoid attach ` talks to one instead; `/codeoid conductor` goes back. +- `/codeoid stop` interrupts the current turn; `/codeoid who` shows who you are signed in as. +- When the session you're talking to asks to run something, a card shows what it would do, the workstream it acts in, its risk and whether it is sandboxed, with Approve and Deny buttons. Only you can press your cards. +- When another of your sessions is waiting on you, you get a message naming it. + +Only the people in `SLACK_ALLOWED_USER_IDS` can use it, and each acts with their own key: what they can do is what that key allows. +Your sign-in ends when the key's token expires; run `/codeoid auth` again. diff --git a/src/cli.ts b/src/cli.ts index d7b398cc..195f4823 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -94,6 +94,7 @@ program .option("-p, --port ", "Port to listen on", "7400") .option("--host ", "Host to bind to", "127.0.0.1") .option("--no-telegram", "Disable Telegram bot") + .option("--no-slack", "Disable the Slack frontend") .option("--no-web", "Disable Web UI") .option( "--local", @@ -242,6 +243,25 @@ program } } + // Slack (enabled when SLACK_APP_TOKEN and SLACK_BOT_TOKEN are set; docs/slack.md) + if (opts.slack !== false) { + const appToken = process.env.SLACK_APP_TOKEN; + const botToken = process.env.SLACK_BOT_TOKEN; + const allowed = (process.env.SLACK_ALLOWED_USER_IDS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if ((appToken || botToken) && localMode) { + // Reached through Slack's servers: a remote surface, which local mode's trust model doesn't cover. + console.warn("[codeoid] local mode: Slack frontend NOT started (it needs ZeroID identities). Run `codeoid login` and start without --local to use it."); + } else if (appToken && botToken && allowed.length > 0) { + const { SlackFrontend } = await import("./frontends/slack/index.js"); + daemon.use(new SlackFrontend(appToken, botToken, allowed)); + } else if (appToken || botToken) { + console.log("[codeoid] Slack needs SLACK_APP_TOKEN, SLACK_BOT_TOKEN and SLACK_ALLOWED_USER_IDS — skipping Slack"); + } + } + try { await daemon.start(); } catch (err) { diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 9b5bb56f..dc48407e 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -639,6 +639,20 @@ export class SessionManager { * session next reaches a resting status (see #statusObserver). */ #phaseWaiters = new Map void>(); /** Stable observer identity — every Session reports status transitions here. */ + #needsOwnerListeners = new Set<(sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }) => void>(); + + /** + * Hear when a session starts waiting on its owner (an approval): its id, + * name and owner. For a frontend that tells the owner where they're needed; + * it reads the rest through the owner's own token. Returns the unsubscribe. + */ + onNeedsOwner(listener: (sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }) => void): () => void { + this.#needsOwnerListeners.add(listener); + return () => { + this.#needsOwnerListeners.delete(listener); + }; + } + #statusObserver = (sessionId: string, status: SessionInfo["status"], opts?: { settled: true }): void => { // A turn settled without a status change: only what waits on spend cares (#430). if (opts?.settled) { @@ -652,6 +666,21 @@ export class SessionManager { // owner's registered devices off-LAN. Fire-and-forget — a push hiccup must // never touch the status path (the emit itself only ever sees an opaque // session id, never the tool's args/description). + // Frontends that tell an owner a session waits on them (Slack's "needs you"): told the same moment, by id + // and owner only; what the session waits on is theirs to read through the owner's own token. + if (status === "waiting_approval" && this.#needsOwnerListeners.size > 0) { + const session = this.#sessions.get(sessionId); + if (session) { + const owner = { sub: session.createdBy, accountId: session.accountId, projectId: session.projectId }; + for (const listener of this.#needsOwnerListeners) { + try { + listener(sessionId, session.name, owner); + } catch (err) { + console.error("[codeoid] needs-owner listener failed:", err); + } + } + } + } if (status === "waiting_approval" && this.#pushService.enabled) { const session = this.#sessions.get(sessionId); if (session) { diff --git a/src/frontends/slack/index.ts b/src/frontends/slack/index.ts new file mode 100644 index 00000000..fb369cb9 --- /dev/null +++ b/src/frontends/slack/index.ts @@ -0,0 +1,532 @@ +/** + * Slack frontend: talk to codeoid's conductor from a Slack DM. + * + * Runs inside the daemon, over Socket Mode (socket.ts), so it needs no public + * URL. Enabled when SLACK_APP_TOKEN, SLACK_BOT_TOKEN and + * SLACK_ALLOWED_USER_IDS are set; ZeroID only, never in local mode. + * + * - `/codeoid auth `: sign in with your own ZeroID key. A slash + * command is never posted to the conversation, so the key isn't left in it. + * Everything after runs under that identity, as Telegram does. + * - A DM to the bot goes to the conductor (made on first use), or to the + * session you attached with `/codeoid attach `. Replies come back in + * the DM. + * - A tool waiting for approval in that session gets a card with Approve and + * Deny buttons, naming the workstream it acts in. Only the person it was + * sent to can press them. + * - When another of your sessions starts waiting on you, a DM says so. + * + * Other people's Slack messages never reach a session from here (S2 adds + * intake, as data). + */ + +import { randomUUID } from "node:crypto"; +import { approvalSandboxLine, formatCollaborationCost, formatToolRisk } from "@highflame/codeoid-core"; +import { ALL_SCOPES_STRING } from "../../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo, ToolInfo } from "../../protocol/types.js"; +import type { AuthConfig } from "../../daemon/auth.js"; +import type { AttachedClient } from "../../daemon/session.js"; +import type { SessionManager } from "../../daemon/session-manager.js"; +import type { Store } from "../../daemon/store.js"; +import type { TokenVerifier } from "../../daemon/verifier.js"; +import type { Frontend, FrontendContext } from "../types.js"; +import { defaultTransport, type SlackEnvelope, SlackSocket, type SlackTransport, slackApi } from "./socket.js"; + +/** An approval card awaiting a button press, keyed `${slackUserId}:${approvalId}`. */ +interface PendingApproval { + approvalId: string; + sessionId: string; + userId: string; + channel: string; + ts?: string; + createdAt: number; +} + +interface UserState { + auth: AuthContext | null; + /** The token behind `auth`, kept for the conductor's owner → conductor delegation. Never logged. */ + rawToken: string | null; + attachedSessionId: string | null; + attachedName: string | null; + clientId: string; + /** Their DM with the bot, where replies and cards go. */ + dmChannel: string | null; + /** Replies streaming in, by message id, not yet posted. */ + streams: Map; + /** Posts to this user, in order. */ + outbox: Promise; +} + +const APPROVAL_TTL_MS = 60 * 60 * 1000; +const MAX_PENDING_APPROVALS = 200; +/** Slack's section text limit is 3000; a message's 40 000. Replies are split below the first. */ +const CHUNK = 2_900; + +export interface SlackFrontendOptions { + /** Transport for Slack (tests inject one). */ + transport?: SlackTransport; + approvalTtlMs?: number; +} + +export class SlackFrontend implements Frontend { + readonly name = "slack"; + + #appToken: string; + #botToken: string; + #allowed: Set; + #transport: SlackTransport | undefined; + #approvalTtlMs: number; + #socket: SlackSocket | null = null; + #manager!: SessionManager; + #authConfig!: AuthConfig; + #verifier!: TokenVerifier; + #store!: Store; + #users = new Map(); + #approvals = new Map(); + #unsubscribe: (() => void) | null = null; + + constructor(appToken: string, botToken: string, allowedUserIds: string[], opts: SlackFrontendOptions = {}) { + this.#appToken = appToken; + this.#botToken = botToken; + this.#allowed = new Set(allowedUserIds); + this.#transport = opts.transport; + this.#approvalTtlMs = opts.approvalTtlMs ?? APPROVAL_TTL_MS; + } + + async start(ctx: FrontendContext): Promise { + this.#manager = ctx.manager; + this.#authConfig = ctx.auth; + this.#verifier = ctx.verifier; + this.#store = ctx.store; + // Slack is reached through Slack's servers: a remote surface, which local mode's trust model doesn't cover. + if (ctx.verifier.mode !== "zeroid") { + throw new Error("the Slack frontend requires ZeroID auth — it is not available in local mode (run `codeoid login`, then start without --local)"); + } + this.#unsubscribe = this.#manager.onNeedsOwner((sessionId, name, owner) => this.#needsYou(sessionId, name, owner)); + this.#socket = new SlackSocket(this.#appToken, (env) => this.handleEnvelope(env), this.#transport); + await this.#socket.start(); + } + + async stop(): Promise { + this.#unsubscribe?.(); + this.#socket?.stop(); + for (const state of this.#users.values()) { + try { + this.#manager.disconnectClient(state.clientId); + } catch {} + } + } + + /** One Socket Mode envelope; what it returns is the ack's payload (a slash command's ephemeral reply). Public for tests. */ + async handleEnvelope(env: SlackEnvelope): Promise | undefined> { + if (env.type === "slash_commands") return this.#slashCommand(env.payload); + if (env.type === "events_api") { + const event = env.payload.event as Record | undefined; + if (event) await this.#event(event); + return undefined; + } + if (env.type === "interactive") { + await this.#interactive(env.payload); + return undefined; + } + return undefined; + } + + // ── Slash command: /codeoid ────────────────────────────── + + async #slashCommand(p: Record): Promise> { + const userId = String(p.user_id ?? ""); + const reply = (text: string) => ({ response_type: "ephemeral", text }); + if (!this.#allowed.has(userId)) return reply("You're not on this codeoid's allowlist."); + const [sub = "help", ...args] = String(p.text ?? "").trim().split(/\s+/); + switch (sub.toLowerCase()) { + case "auth": + return reply(await this.#auth(userId, args[0])); + case "ls": + case "sessions": + return reply(await this.#list(userId)); + case "attach": + return reply(await this.#attachByName(userId, args.join(" "))); + case "conductor": + return reply(await this.#attachConductor(userId)); + case "interrupt": + case "stop": + return reply(await this.#interrupt(userId)); + case "who": { + const state = this.#authed(userId); + if (typeof state === "string") return reply(state); + return reply(`Signed in as ${state.auth?.name ?? state.auth?.sub}. Talking to ${state.attachedName ?? "the conductor (on your next message)"}.`); + } + default: + return reply( + [ + "*codeoid*", + "`/codeoid auth ` sign in with your ZeroID key (once)", + "`/codeoid ls` your sessions · `/codeoid attach ` talk to one · `/codeoid conductor` back to the conductor", + "`/codeoid stop` interrupt the current turn · `/codeoid who` who you are signed in as", + "Message me here to talk to the conductor. Approvals come here with buttons.", + ].join("\n"), + ); + } + } + + async #auth(userId: string, key: string | undefined): Promise { + if (!key) return "Usage: `/codeoid auth `"; + try { + let token = key; + if (key.startsWith("zid_sk_")) { + const resp = await fetch(`${this.#authConfig.baseUrl}/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grant_type: "api_key", api_key: key, scope: ALL_SCOPES_STRING }), + }); + if (!resp.ok) return "Sign-in failed: check the key."; + token = ((await resp.json()) as { access_token: string }).access_token; + } + const auth = await this.#verifier.verify(token); + const state = this.#state(userId); + // A new identity: what the old one was attached to and asked is let go. + this.#forget(userId, state); + state.auth = auth; + state.rawToken = token; + this.#store.audit(auth.sub, "slack.auth", undefined, `slack_user_id=${userId}${auth.name ? ` name=${auth.name}` : ""}`); + return `Signed in as ${auth.name ?? auth.sub}. Message me to talk to the conductor.`; + } catch (err) { + return `Sign-in failed: ${err instanceof Error ? err.message : "unknown error"}`; + } + } + + async #list(userId: string): Promise { + const state = this.#authed(userId); + if (typeof state === "string") return state; + const resp = await this.#manager.handle({ type: "session.list", id: randomUUID() }, state.auth!, this.#client(userId, state)); + if (resp.type !== "session.list.result") return resp.type === "response.error" ? `Error: ${resp.error}` : "No sessions."; + const sessions = (resp as { sessions: SessionInfo[] }).sessions; + if (sessions.length === 0) return "No sessions."; + return sessions + .slice(0, 40) + .map((s) => `• *${esc(s.name)}* — ${s.status}${s.workstream?.id ? ` · ${esc(s.workstream.id)}` : ""}${s.id === state.attachedSessionId ? " ← here" : ""}`) + .join("\n"); + } + + async #attachByName(userId: string, name: string): Promise { + const state = this.#authed(userId); + if (typeof state === "string") return state; + if (!name) return "Usage: `/codeoid attach `"; + const session = this.#manager.findByName(name, state.auth!); + if (!session) return `No session named ${esc(name)}. \`/codeoid ls\` lists them.`; + return this.#attach(userId, state, session.id, session.name); + } + + async #attachConductor(userId: string): Promise { + const state = this.#authed(userId); + if (typeof state === "string") return state; + const id = await this.#conductorId(userId, state); + if (typeof id !== "string") return id.error; + return this.#attach(userId, state, id, "conductor"); + } + + /** The caller's conductor, made on first use with their own token (the owner → conductor delegation needs it). */ + async #conductorId(userId: string, state: UserState): Promise { + const resp = await this.#manager.handle( + { type: "session.create", id: randomUUID(), name: "conductor", workdir: ".", role: "conductor" }, + state.auth!, + this.#client(userId, state), + state.rawToken ? { rawToken: state.rawToken } : undefined, + ); + if (resp.type === "response.ok") return ((resp as { data?: SessionInfo }).data as SessionInfo).id; + return { error: resp.type === "response.error" ? `The conductor isn't available: ${resp.error}` : "The conductor isn't available." }; + } + + async #attach(userId: string, state: UserState, sessionId: string, name: string): Promise { + if (state.attachedSessionId && state.attachedSessionId !== sessionId) { + this.#manager.disconnectClient(state.clientId); + state.streams.clear(); + } + const prev = { id: state.attachedSessionId, name: state.attachedName }; + state.attachedSessionId = sessionId; + state.attachedName = name; + const resp = await this.#manager.handle({ type: "session.attach", id: randomUUID(), sessionId }, state.auth!, this.#client(userId, state)); + if (resp.type === "response.error") { + state.attachedSessionId = prev.id; + state.attachedName = prev.name; + return `Couldn't attach: ${resp.error}`; + } + return `Talking to *${esc(name)}* now.`; + } + + async #interrupt(userId: string): Promise { + const state = this.#authed(userId); + if (typeof state === "string") return state; + if (!state.attachedSessionId) return "Nothing to stop."; + const resp = await this.#manager.handle({ type: "session.interrupt", id: randomUUID(), sessionId: state.attachedSessionId }, state.auth!, this.#client(userId, state)); + return resp.type === "response.error" ? `Error: ${resp.error}` : "Stopped."; + } + + // ── Events: a DM to the bot ─────────────────────────────────────────── + + async #event(event: Record): Promise { + // Only people's own messages in a DM with the bot: never a bot's (our own replies), an edit or a join. + if (event.type !== "message" || event.channel_type !== "im" || event.bot_id || event.subtype) return; + const userId = String(event.user ?? ""); + const channel = String(event.channel ?? ""); + const text = String(event.text ?? "").trim(); + if (!userId || !channel || !text) return; + if (!this.#allowed.has(userId)) { + await this.#post(channel, "You're not on this codeoid's allowlist."); + return; + } + const state = this.#state(userId); + state.dmChannel = channel; + const authed = this.#authed(userId); + if (typeof authed === "string") { + await this.#post(channel, authed); + return; + } + if (!state.attachedSessionId) { + const id = await this.#conductorId(userId, state); + if (typeof id !== "string") { + await this.#post(channel, id.error); + return; + } + const attached = await this.#attach(userId, state, id, "conductor"); + if (!attached.startsWith("Talking")) { + await this.#post(channel, attached); + return; + } + } + const resp = await this.#manager.handle( + { type: "session.send", id: randomUUID(), sessionId: state.attachedSessionId!, text }, + state.auth!, + this.#client(userId, state), + ); + if (resp.type === "response.error") await this.#post(channel, `⚠️ Not delivered: ${resp.error}`); + } + + // ── What a session says, to the DM ──────────────────────────────────── + + #forward(userId: string, msg: DaemonMessage): void { + const state = this.#users.get(userId); + if (!state?.dmChannel) return; + // Another session's broadcast, after a switch: not this DM's. + if ("sessionId" in msg && typeof msg.sessionId === "string" && msg.sessionId !== state.attachedSessionId) return; + const channel = state.dmChannel; + if (msg.type === "session.message") { + const m = msg; + if (m.role === "assistant") { + // A reply streams as deltas; its final broadcast carries the whole text, but not always: what + // hasn't been posted is posted at the turn's end. + if (m.content) { + state.streams.delete(m.messageId); + this.#say(userId, channel, m.content); + } else if (!state.streams.has(m.messageId)) state.streams.set(m.messageId, ""); + } else if (m.role === "tool_call" && m.tool?.state.phase === "waiting_confirmation" && "approvalId" in m.tool.state) { + const tool = m.tool; + this.#queue(userId, () => this.#approvalCard(userId, state, tool)); + } else if (m.role === "system" && m.content) { + this.#say(userId, channel, `⚠️ ${m.content}`); + } + return; + } + if (msg.type === "session.message.delta") { + if (msg.contentAppend && state.streams.has(msg.messageId)) state.streams.set(msg.messageId, (state.streams.get(msg.messageId) ?? "") + msg.contentAppend); + return; + } + if (msg.type === "session.status_change" && (msg.status === "idle" || msg.status === "error")) { + for (const text of state.streams.values()) if (text.trim()) this.#say(userId, channel, text); + state.streams.clear(); + if (msg.status === "error") this.#say(userId, channel, "❌ The turn ended with an error."); + return; + } + if (msg.type === "session.ui_request") { + this.#say(userId, channel, `❓ ${msg.title} — answer it in the web UI or with \`codeoid attach\`.`); + } + } + + /** Post `text` after everything already queued for this user, so replies and cards keep their order. */ + #say(userId: string, channel: string, text: string): void { + this.#queue(userId, () => this.#post(channel, text)); + } + + #queue(userId: string, job: () => Promise): void { + const state = this.#users.get(userId); + if (!state) return; + state.outbox = state.outbox.then(job, job).catch(() => {}); + } + + // ── Approval cards ──────────────────────────────────────────────────── + + async #approvalCard(userId: string, state: UserState, tool: ToolInfo): Promise { + const st = tool.state; + if (st.phase !== "waiting_confirmation") return; + this.#prune(); + const key = `${userId}:${st.approvalId}`; + if (this.#approvals.has(key)) return; + const pending: PendingApproval = { approvalId: st.approvalId, sessionId: state.attachedSessionId!, userId, channel: state.dmChannel!, createdAt: Date.now() }; + this.#approvals.set(key, pending); + const lines = [`*Approval needed* — \`${esc(tool.name)}\``, esc(String(st.description ?? tool.name)).slice(0, 2_000)]; + const tw = st.targetWorkstream; + if (tw?.workstream) lines.push(`⌂ workstream *${esc(tw.workstream)}*`); + if (tw?.warning) lines.push(`⚠ ${esc(tw.warning)}`); + const risk = formatToolRisk(st.risk); + if (risk) lines.push(`▲ ${esc(risk)}`); + const sandbox = approvalSandboxLine(st); + if (sandbox) lines.push(esc(sandbox)); + if (st.collaborationCost) lines.push(`◇ ${esc(formatCollaborationCost(st.collaborationCost))}`); + const text = lines.join("\n"); + try { + const res = await slackApi<{ ts: string }>(this.#transportOrDefault(), this.#botToken, "chat.postMessage", { + channel: pending.channel, + text: `Approval needed — ${tool.name}`, + blocks: [ + { type: "section", text: { type: "mrkdwn", text: text.slice(0, CHUNK) } }, + { + type: "actions", + block_id: "codeoid_approval", + elements: [ + { type: "button", style: "primary", text: { type: "plain_text", text: "Approve" }, action_id: "approve", value: st.approvalId }, + { type: "button", style: "danger", text: { type: "plain_text", text: "Deny" }, action_id: "deny", value: st.approvalId }, + ], + }, + ], + }); + pending.ts = res.ts; + } catch (err) { + console.error(`[codeoid:slack] approval card failed: ${err instanceof Error ? err.message : String(err)}`); + await this.#post(pending.channel, `⚠️ An approval is waiting that couldn't be shown here — answer it in the web UI or with \`codeoid approve\`.`); + } + } + + async #interactive(p: Record): Promise { + if (p.type !== "block_actions") return; + const userId = String((p.user as { id?: unknown } | undefined)?.id ?? ""); + const action = ((p.actions as Array<{ action_id?: string; value?: string }> | undefined) ?? [])[0]; + if (!action || (action.action_id !== "approve" && action.action_id !== "deny")) return; + const channel = String((p.channel as { id?: unknown } | undefined)?.id ?? (p.container as { channel_id?: unknown } | undefined)?.channel_id ?? ""); + const ts = String((p.message as { ts?: unknown } | undefined)?.ts ?? (p.container as { message_ts?: unknown } | undefined)?.message_ts ?? ""); + const say = (text: string) => (channel && ts ? this.#update(channel, ts, text) : Promise.resolve()); + if (!this.#allowed.has(userId)) return; + // Keyed by the presser: a card shared with someone else can't be pressed by them. + const key = `${userId}:${action.value ?? ""}`; + const pending = this.#approvals.get(key); + if (!pending) { + const someoneElses = [...this.#approvals.keys()].some((k) => k.endsWith(`:${action.value}`)); + await say(someoneElses ? "This approval isn't yours." : "This approval is no longer waiting."); + return; + } + this.#approvals.delete(key); + if (Date.now() - pending.createdAt > this.#approvalTtlMs) { + await say("This approval expired."); + return; + } + const state = this.#authed(userId); + if (typeof state === "string") { + await say(state); + return; + } + const approved = action.action_id === "approve"; + const resp = await this.#manager.handle( + { type: "session.approve", id: randomUUID(), sessionId: pending.sessionId, approvalId: pending.approvalId, approved }, + state.auth!, + this.#client(userId, state), + ); + if (resp.type === "response.error") await say(`⚠️ ${resp.error}`); + else await say(approved ? "✅ Approved" : "❌ Denied"); + } + + #prune(): void { + const now = Date.now(); + for (const [k, a] of this.#approvals) if (now - a.createdAt > this.#approvalTtlMs) this.#approvals.delete(k); + while (this.#approvals.size >= MAX_PENDING_APPROVALS) { + const oldest = this.#approvals.keys().next().value; + if (!oldest) break; + this.#approvals.delete(oldest); + } + } + + // ── "Needs you": another of your sessions waits on you ──────────────── + + #needsYou(sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }): void { + for (const [userId, state] of this.#users) { + const a = state.auth; + const channel = state.dmChannel; + if (!a || !channel || this.#expired(state)) continue; + if (a.sub !== owner.sub || a.accountId !== owner.accountId || a.projectId !== owner.projectId) continue; + // The attached session's cards come with the conversation. + if (state.attachedSessionId === sessionId) continue; + this.#queue(userId, () => this.#post(channel, `🔔 *${esc(name)}* is waiting for you. \`/codeoid attach ${esc(name)}\` to answer it here.`)); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + #state(userId: string): UserState { + let s = this.#users.get(userId); + if (!s) { + s = { auth: null, rawToken: null, attachedSessionId: null, attachedName: null, clientId: `slack:${userId}`, dmChannel: null, streams: new Map(), outbox: Promise.resolve() }; + this.#users.set(userId, s); + } + return s; + } + + /** The user's state when signed in and unexpired, else what to tell them. Every command re-checks expiry. */ + #authed(userId: string): UserState | string { + const state = this.#users.get(userId); + if (!state?.auth) return "Not signed in. Run `/codeoid auth `."; + if (this.#expired(state)) { + this.#forget(userId, state); + return "Your sign-in expired. Run `/codeoid auth ` again."; + } + return state; + } + + #expired(state: UserState): boolean { + const exp = state.auth?.exp; + return typeof exp === "number" && exp > 0 && exp <= Math.floor(Date.now() / 1000); + } + + /** Let go of a user's identity, attachment and pending cards: a card is never answered under another sign-in. */ + #forget(userId: string, state: UserState): void { + state.auth = null; + state.rawToken = null; + try { + this.#manager.disconnectClient(state.clientId); + } catch {} + state.attachedSessionId = null; + state.attachedName = null; + state.streams.clear(); + for (const [k, a] of this.#approvals) if (a.userId === userId) this.#approvals.delete(k); + } + + #client(userId: string, state: UserState): AttachedClient { + return { id: state.clientId, auth: state.auth!, send: (msg: DaemonMessage) => this.#forward(userId, msg) }; + } + + #transportOrDefault(): SlackTransport { + return this.#transport ?? defaultTransport; + } + + async #post(channel: string, text: string): Promise { + for (let i = 0; i < text.length || i === 0; i += CHUNK) { + try { + await slackApi(this.#transportOrDefault(), this.#botToken, "chat.postMessage", { channel, text: text.slice(i, i + CHUNK), mrkdwn: true }); + } catch (err) { + console.error(`[codeoid:slack] post failed: ${err instanceof Error ? err.message : String(err)}`); + return; + } + } + } + + async #update(channel: string, ts: string, text: string): Promise { + try { + await slackApi(this.#transportOrDefault(), this.#botToken, "chat.update", { channel, ts, text, blocks: [] }); + } catch (err) { + console.error(`[codeoid:slack] update failed: ${err instanceof Error ? err.message : String(err)}`); + } + } +} + +/** Slack's three control characters, escaped for mrkdwn (a name or text can't make a mention or a link). */ +export function esc(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} diff --git a/src/frontends/slack/socket.ts b/src/frontends/slack/socket.ts new file mode 100644 index 00000000..58bfa199 --- /dev/null +++ b/src/frontends/slack/socket.ts @@ -0,0 +1,184 @@ +/** + * A minimal Slack Socket Mode client and Web API caller. + * + * Socket Mode is an outbound WebSocket the app opens to Slack, so the daemon + * needs no public URL (the same reason Telegram uses long polling). Slack + * sends each event, button press and slash command as an envelope; the client + * acknowledges it within three seconds, optionally with a response payload + * (an ephemeral reply to a slash command), and hands it on. + * + * Kept to what the frontend uses: open a connection, acknowledge envelopes, + * reconnect when Slack asks or the socket drops, and call Web API methods. + */ + +export interface SlackEnvelope { + envelope_id: string; + type: "events_api" | "interactive" | "slash_commands" | string; + payload: Record; + accepts_response_payload?: boolean; +} + +/** A WebSocket as the client uses it (injectable for tests). */ +export interface SocketLike { + send(data: string): void; + close(): void; + onopen: (() => void) | null; + onmessage: ((ev: { data: unknown }) => void) | null; + onclose: (() => void) | null; + onerror: ((ev: unknown) => void) | null; +} + +export interface SlackTransport { + fetch(input: string, init?: RequestInit): Promise; + connect(url: string): SocketLike; +} + +export const defaultTransport: SlackTransport = { + fetch: (input, init) => fetch(input, init), + connect: (url) => new WebSocket(url) as unknown as SocketLike, +}; + +const API = "https://slack.com/api"; + +/** A Web API error: Slack answered `ok: false`, or the call failed. */ +export class SlackApiError extends Error { + constructor( + readonly method: string, + readonly code: string, + ) { + super(`slack ${method}: ${code}`); + } +} + +/** + * Call a Web API method with a token. Retries once after a 429's + * `Retry-After`; anything else that isn't `ok` throws SlackApiError. + */ +export async function slackApi>( + transport: SlackTransport, + token: string, + method: string, + body: Record, +): Promise { + for (let attempt = 0; ; attempt++) { + const res = await transport.fetch(`${API}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8", Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); + if (res.status === 429 && attempt === 0) { + const wait = Math.min(30, Number(res.headers.get("retry-after")) || 1); + await Bun.sleep(wait * 1000); + continue; + } + const json = (await res.json().catch(() => ({ ok: false, error: `http_${res.status}` }))) as { ok?: boolean; error?: string } & T; + if (!json.ok) throw new SlackApiError(method, json.error ?? `http_${res.status}`); + return json; + } +} + +/** + * One Socket Mode connection, kept open: reconnects when Slack sends a + * `disconnect` (it refreshes connections every few hours) or the socket + * drops, backing off from 1 s to 30 s while it can't connect. + */ +export class SlackSocket { + #appToken: string; + #transport: SlackTransport; + #onEnvelope: (env: SlackEnvelope) => Promise | undefined>; + #socket: SocketLike | null = null; + #stopped = false; + #backoffMs = 1_000; + #timer: ReturnType | null = null; + + constructor( + appToken: string, + onEnvelope: (env: SlackEnvelope) => Promise | undefined>, + transport: SlackTransport = defaultTransport, + ) { + this.#appToken = appToken; + this.#onEnvelope = onEnvelope; + this.#transport = transport; + } + + /** Open the connection; resolves once the first one is open, or throws if Slack refuses the app token. */ + async start(): Promise { + this.#stopped = false; + await this.#open(); + } + + stop(): void { + this.#stopped = true; + if (this.#timer) clearTimeout(this.#timer); + this.#timer = null; + const s = this.#socket; + this.#socket = null; + s?.close(); + } + + async #open(): Promise { + const { url } = await slackApi<{ url: string }>(this.#transport, this.#appToken, "apps.connections.open", {}); + if (this.#stopped) return; + const socket = this.#transport.connect(url); + this.#socket = socket; + socket.onopen = () => { + this.#backoffMs = 1_000; + }; + socket.onmessage = (ev) => void this.#receive(socket, ev.data); + socket.onerror = () => {}; + socket.onclose = () => { + if (this.#socket === socket) this.#socket = null; + this.#reconnect(); + }; + } + + #reconnect(): void { + if (this.#stopped || this.#timer) return; + const wait = this.#backoffMs; + this.#backoffMs = Math.min(30_000, this.#backoffMs * 2); + this.#timer = setTimeout(() => { + this.#timer = null; + this.#open().catch((err) => { + console.error(`[codeoid:slack] reconnecting failed: ${err instanceof Error ? err.message : String(err)}`); + this.#reconnect(); + }); + }, wait); + } + + async #receive(socket: SocketLike, data: unknown): Promise { + let msg: { type?: string; envelope_id?: string; payload?: unknown; accepts_response_payload?: boolean }; + try { + msg = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer)); + } catch { + return; + } + if (msg.type === "hello") return; + if (msg.type === "disconnect") { + // Slack is about to close this connection: open the next one now, then let this one go. + const old = socket; + if (this.#socket === old) this.#socket = null; + old.onclose = null; + old.close(); + this.#reconnect(); + return; + } + if (!msg.envelope_id || !msg.payload || typeof msg.payload !== "object") return; + const env: SlackEnvelope = { + envelope_id: msg.envelope_id, + type: msg.type ?? "", + payload: msg.payload as Record, + ...(msg.accepts_response_payload ? { accepts_response_payload: true } : {}), + }; + let response: Record | undefined; + try { + response = await this.#onEnvelope(env); + } catch (err) { + console.error(`[codeoid:slack] handling ${env.type} failed: ${err instanceof Error ? err.message : String(err)}`); + } + try { + socket.send(JSON.stringify({ envelope_id: env.envelope_id, ...(response && env.accepts_response_payload ? { payload: response } : {}) })); + } catch { + // The socket closed meanwhile: Slack retries the envelope on the next connection. + } + } +} diff --git a/src/tests/slack-frontend.test.ts b/src/tests/slack-frontend.test.ts new file mode 100644 index 00000000..3ba824f0 --- /dev/null +++ b/src/tests/slack-frontend.test.ts @@ -0,0 +1,207 @@ +/** + * Slack frontend (S1): sign-in by slash command, a DM to the conductor, + * approval cards only their own user can press, and "needs you" notices — + * against a real SessionManager and a scripted backend, with Slack's side a + * recording fake. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodeoidConfig } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import type { TokenVerifier } from "../daemon/verifier.js"; +import { SlackFrontend, esc } from "../frontends/slack/index.js"; +import type { SlackTransport, SocketLike } from "../frontends/slack/socket.js"; +import type { FrontendContext } from "../frontends/types.js"; +import { ALL_SCOPES } from "../protocol/scopes.js"; +import type { AuthContext } from "../protocol/types.js"; + +const OWNER = "U_OWNER"; +const OTHER = "U_OTHER"; +const STRANGER = "U_STRANGER"; +const AUTH: Record = { + "tok-owner": { sub: "user:owner", name: "Owner", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }, + "tok-other": { sub: "user:other", name: "Other", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }, + "tok-expired": { sub: "user:owner", name: "Owner", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj", exp: 1 }, +}; +const done: ProviderEvent = { type: "turn_done", result: { providerId: "mock", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } } as ProviderEvent; + +let tmp: string; +let store: Store; +let transcript: TranscriptStore; +let manager: SessionManager; +let script: ProviderEvent[][]; +let providers: MockSessionProvider[]; +let calls: Array<{ method: string; body: Record }>; +let frontend: SlackFrontend; + +function transport(): SlackTransport { + return { + fetch: async (url, init) => { + const method = String(url).split("/api/")[1] ?? ""; + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + calls.push({ method, body }); + if (method === "apps.connections.open") return Response.json({ ok: true, url: "wss://test" }); + return Response.json({ ok: true, ts: `${calls.length}.0` }); + }, + connect: () => ({ send() {}, close() {}, onopen: null, onmessage: null, onclose: null, onerror: null }) as SocketLike, + }; +} + +beforeEach(async () => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-slack-")); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + providers = []; + calls = []; + script = [[{ type: "text_done", content: "Hello from the conductor." } as ProviderEvent, done]]; + const config = { + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "transcripts"), + auth: { baseUrl: "http://localhost:1" }, + zeroidUrl: "http://localhost:1", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: {}, + conductor: { enabled: true, name: "conductor", provider: "claude" }, + } as unknown as CodeoidConfig; + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config, + _testProviderFactory: () => { + const p = new MockSessionProvider("mock", script); + providers.push(p); + return p; + }, + }); + const verifier = { + mode: "zeroid", + verify: async (token: string) => { + const a = AUTH[token]; + if (!a) throw new Error("invalid token"); + return a; + }, + } as unknown as TokenVerifier; + frontend = new SlackFrontend("xapp-test", "xoxb-test", [OWNER, OTHER], { transport: transport() }); + await frontend.start({ manager, store, auth: { baseUrl: "http://localhost:1" }, verifier, httpServer: null as never, host: "127.0.0.1", port: 0 } as FrontendContext); +}); + +afterEach(async () => { + await frontend.stop(); + try { + await manager.drain(2_000); + } catch {} + try { + await transcript.flush(); + } catch {} + store.close(); + rmSync(tmp, { recursive: true, force: true }); +}); + +const slash = (user: string, text: string) => + frontend.handleEnvelope({ envelope_id: "e", type: "slash_commands", payload: { command: "/codeoid", user_id: user, text, channel_id: "D1" }, accepts_response_payload: true }); +const dm = (user: string, text: string, channel = `D-${user}`) => + frontend.handleEnvelope({ envelope_id: "e", type: "events_api", payload: { event: { type: "message", channel_type: "im", user, text, channel } } }); +const press = (user: string, action: "approve" | "deny", approvalId: string) => + frontend.handleEnvelope({ envelope_id: "e", type: "interactive", payload: { type: "block_actions", user: { id: user }, actions: [{ action_id: action, value: approvalId }], channel: { id: `D-${OWNER}` }, message: { ts: "9.0" } } }); +const posts = () => calls.filter((c) => c.method === "chat.postMessage").map((c) => String(c.body.text)); + +async function until(cond: () => boolean, ms = 3_000): Promise { + const end = Date.now() + ms; + while (!cond()) { + if (Date.now() > end) throw new Error("condition not reached"); + await Bun.sleep(5); + } +} + +describe("sign-in", () => { + it("only allowlisted Slack users; a key signs them in, and the sign-in is audited", async () => { + expect(await slash(STRANGER, "auth tok-owner")).toMatchObject({ text: expect.stringContaining("allowlist") }); + expect(await slash(OWNER, "ls")).toMatchObject({ text: expect.stringContaining("Not signed in") }); + expect(await slash(OWNER, "auth bad")).toMatchObject({ text: expect.stringContaining("failed") }); + expect(await slash(OWNER, "auth tok-owner")).toMatchObject({ text: expect.stringContaining("Signed in as Owner") }); + const audit = store.database.prepare("SELECT action, detail FROM audit_log WHERE action = 'slack.auth'").all() as Array<{ detail: string }>; + expect(audit[0]?.detail).toContain(`slack_user_id=${OWNER}`); + }); + + it("an expired sign-in is refused, and lets go of what it held", async () => { + await slash(OWNER, "auth tok-expired"); + expect(await slash(OWNER, "ls")).toMatchObject({ text: expect.stringContaining("expired") }); + }); + + it("a stranger's DM is refused and never reaches a session", async () => { + await dm(STRANGER, "hi"); + await until(() => posts().some((t) => t.includes("allowlist"))); + expect(providers.length).toBe(0); + }); +}); + +describe("talking to the conductor", () => { + it("a DM goes to the conductor (made on first use) and its reply comes back to the DM", async () => { + await slash(OWNER, "auth tok-owner"); + await dm(OWNER, "what's going on?"); + await until(() => posts().includes("Hello from the conductor.")); + const reply = calls.find((c) => c.body.text === "Hello from the conductor."); + expect(reply?.body.channel).toBe(`D-${OWNER}`); + expect(await slash(OWNER, "who")).toMatchObject({ text: expect.stringContaining("conductor") }); + }); + + it("only the user's own DM messages count: a bot's, an edit or a channel message is ignored", async () => { + await slash(OWNER, "auth tok-owner"); + await frontend.handleEnvelope({ envelope_id: "e", type: "events_api", payload: { event: { type: "message", channel_type: "im", user: OWNER, text: "x", channel: "D", bot_id: "B1" } } }); + await frontend.handleEnvelope({ envelope_id: "e", type: "events_api", payload: { event: { type: "message", channel_type: "channel", user: OWNER, text: "x", channel: "C1" } } }); + await frontend.handleEnvelope({ envelope_id: "e", type: "events_api", payload: { event: { type: "message", subtype: "message_changed", channel_type: "im", user: OWNER, text: "x", channel: "D" } } }); + await Bun.sleep(50); + expect(providers.length).toBe(0); + }); +}); + +describe("approvals", () => { + it("a card with Approve and Deny; another user can't press it; the owner's press decides", async () => { + script = [[{ type: "tool_start", toolId: "t1", sdkToolUseId: "s1", name: "Write", input: { file_path: join(tmp, "a.txt"), content: "x" }, approvalId: "ap-1" } as ProviderEvent, done]]; + await slash(OWNER, "auth tok-owner"); + await slash(OTHER, "auth tok-other"); + await dm(OWNER, "write it"); + await until(() => calls.some((c) => c.method === "chat.postMessage" && Array.isArray(c.body.blocks))); + const card = calls.find((c) => Array.isArray(c.body.blocks))!; + const section = JSON.stringify(card.body.blocks); + expect(section).toContain("Approval needed"); + expect(section).toContain("approve"); + await press(OTHER, "approve", "ap-1"); + await until(() => calls.some((c) => c.method === "chat.update" && String(c.body.text).includes("isn't yours"))); + expect(providers.at(-1)!.canUseToolResults.length).toBe(0); + await press(OWNER, "approve", "ap-1"); + await until(() => (providers.at(-1)?.canUseToolResults.length ?? 0) > 0); + expect(providers.at(-1)!.canUseToolResults[0]!.behavior).toBe("allow"); + await until(() => calls.some((c) => c.method === "chat.update" && String(c.body.text).includes("Approved"))); + // Pressed once: a second press finds nothing waiting. + await press(OWNER, "approve", "ap-1"); + await until(() => calls.some((c) => c.method === "chat.update" && String(c.body.text).includes("no longer waiting"))); + }); + + it("another session of yours waiting on you is told in your DM", async () => { + await slash(OWNER, "auth tok-owner"); + await dm(OWNER, "hello"); + await until(() => posts().includes("Hello from the conductor.")); + script = [[{ type: "tool_start", toolId: "t2", sdkToolUseId: "s2", name: "Write", input: { file_path: join(tmp, "b.txt"), content: "x" }, approvalId: "ap-2" } as ProviderEvent, done]]; + const made = (await manager.handle({ type: "session.create", id: "c", name: "worker-x", workdir: tmp } as never, AUTH["tok-owner"]!, { id: "cli", auth: AUTH["tok-owner"]!, send: () => {} })) as { type: string }; + expect(made.type).toBe("response.ok"); + const session = manager.findByName("worker-x", AUTH["tok-owner"]!)!; + void session.send("go", AUTH["tok-owner"]!); + await until(() => posts().some((t) => t.includes("worker-x") && t.includes("waiting for you"))); + }); +}); + +describe("text", () => { + it("Slack's control characters are escaped, so a name can't make a mention or a link", () => { + expect(esc(" & ")).toBe("<!channel> & <https://x|y>"); + }); +}); diff --git a/src/tests/slack-socket.test.ts b/src/tests/slack-socket.test.ts new file mode 100644 index 00000000..3a830ab0 --- /dev/null +++ b/src/tests/slack-socket.test.ts @@ -0,0 +1,61 @@ +/** + * Slack Socket Mode client: envelopes are acknowledged (with the handler's + * reply when Slack accepts one), and a `disconnect` opens a new connection. + */ +import { describe, expect, it } from "bun:test"; +import { SlackApiError, SlackSocket, type SlackTransport, type SocketLike, slackApi } from "../frontends/slack/socket.js"; + +function fakeSlack() { + const sockets: Array = []; + let opens = 0; + const transport: SlackTransport = { + fetch: async (url) => { + if (String(url).endsWith("apps.connections.open")) { + opens++; + return Response.json({ ok: true, url: `wss://test/${opens}` }); + } + return Response.json({ ok: false, error: "channel_not_found" }); + }, + connect: () => { + const s = { sent: [] as string[], send(d: string) { s.sent.push(d); }, close() {}, onopen: null, onmessage: null, onclose: null, onerror: null } as SocketLike & { sent: string[] }; + sockets.push(s); + return s; + }, + }; + return { transport, sockets, opens: () => opens }; +} + +describe("SlackSocket", () => { + it("acknowledges each envelope, with the handler's reply when Slack accepts one", async () => { + const slack = fakeSlack(); + const seen: string[] = []; + const socket = new SlackSocket("xapp", async (env) => { + seen.push(env.type); + return env.type === "slash_commands" ? { text: "hi" } : undefined; + }, slack.transport); + await socket.start(); + const s = slack.sockets[0]!; + s.onmessage?.({ data: JSON.stringify({ type: "hello" }) }); + s.onmessage?.({ data: JSON.stringify({ envelope_id: "e1", type: "slash_commands", payload: { text: "x" }, accepts_response_payload: true }) }); + s.onmessage?.({ data: JSON.stringify({ envelope_id: "e2", type: "events_api", payload: { event: {} } }) }); + await Bun.sleep(10); + expect(seen).toEqual(["slash_commands", "events_api"]); + expect(s.sent.map((x) => JSON.parse(x))).toEqual([{ envelope_id: "e1", payload: { text: "hi" } }, { envelope_id: "e2" }]); + socket.stop(); + }); + + it("a disconnect from Slack opens a new connection", async () => { + const slack = fakeSlack(); + const socket = new SlackSocket("xapp", async () => undefined, slack.transport); + await socket.start(); + slack.sockets[0]!.onmessage?.({ data: JSON.stringify({ type: "disconnect", reason: "refresh_requested" }) }); + for (let i = 0; i < 300 && slack.opens() < 2; i++) await Bun.sleep(10); + expect(slack.opens()).toBe(2); + socket.stop(); + }); + + it("a Web API error throws with Slack's code", async () => { + const slack = fakeSlack(); + await expect(slackApi(slack.transport, "xoxb", "chat.postMessage", {})).rejects.toBeInstanceOf(SlackApiError); + }); +}); From b5b492a0c29ab96fc524855ead274c93bf8c028e Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Fri, 13 Nov 2026 16:30:59 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20Slack=20S1=20audit=20round=201=20?= =?UTF-8?q?=E2=80=94=20a=20card=20answered=20only=20under=20the=20sign-in?= =?UTF-8?q?=20it=20came=20to,=20fast=20acks=20without=20duplicate=20prompt?= =?UTF-8?q?s,=20pasted=20keys=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a card is bound to the sign-in and session it came with; re-signing in drops it (the Telegram GHSA-4g69 rule) - events and presses are acknowledged at once and handled in order; a retried event is dropped; a slow slash command answers at response_url; a refreshed connection replaces the old one only once it is up - a key or token pasted into the DM is never sent on - codeoid's Slack variables are CODEOID_SLACK_* (kept from every agent and server config like the rest of CODEOID_); settings manifest entries - attaching shows what the session already waits on; a card answered elsewhere closes (PendingToolApproval.messageId) - model text escaped, no unfurling; the DM opened at sign-in (im:write); question tools point to the web UI; cut text says so; optional CODEOID_SLACK_TEAM_ID; notices rate-limited; API calls time out Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- docs/slack.md | 14 ++-- packages/protocol/src/approvals.ts | 2 + src/cli.ts | 13 ++-- src/daemon/session-manager.ts | 4 +- src/daemon/session.ts | 2 + src/daemon/settings/manifest.ts | 13 ++++ src/frontends/slack/index.ts | 118 +++++++++++++++++++++++++---- src/frontends/slack/socket.ts | 103 ++++++++++++++++++++----- src/tests/slack-frontend.test.ts | 67 +++++++++++++++- src/tests/slack-socket.test.ts | 54 ++++++++++++- 11 files changed, 340 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e8023390..00bda459 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,7 @@ src/ │ ├── types.ts # Frontend plugin interface │ ├── index.ts │ ├── telegram/index.ts # Grammy bot, embedded in daemon process +│ ├── slack/ # Slack app over Socket Mode (socket.ts) and the frontend (index.ts), docs/slack.md │ └── web-ui/index.ts # Serves the built SolidJS app (web/dist) at /ui, also Telegram Mini App └── terminal/ └── client.ts # WebSocket client, connects to daemon @@ -605,7 +606,7 @@ ZEROID_URL=highflame # issuer: preset (highflame | highflame-dev ZEROID_ISSUER= # override the expected `iss` claim (default: resolved ZEROID_URL) TELEGRAM_BOT_TOKEN=... # Optional: enables Telegram frontend TELEGRAM_ALLOWED_USER_IDS=123,456 # Required with bot token -SLACK_APP_TOKEN=xapp-... # Optional, with SLACK_BOT_TOKEN and SLACK_ALLOWED_USER_IDS: the Slack frontend (docs/slack.md) +CODEOID_SLACK_APP_TOKEN=xapp-... # Optional, with CODEOID_SLACK_BOT_TOKEN and CODEOID_SLACK_ALLOWED_USER_IDS: the Slack frontend (docs/slack.md) CODEOID_MCP_CONTROL_ENABLED=false # mount /mcp/control (docs/mcp-control.md) CODEOID_MCP_CONTROL_ACTIONS=false # also allow create / send / interrupt / destroy there CODEOID_ROUTINES_ENABLED=true # scheduler + routine commands (docs/routines.md) diff --git a/docs/slack.md b/docs/slack.md index 9a596cfe..f42bbd4e 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -29,7 +29,7 @@ Slack works only with ZeroID sign-in, never in local mode. - chat:write - commands - im:history - - im:read + - im:write settings: event_subscriptions: bot_events: @@ -45,9 +45,10 @@ Slack works only with ZeroID sign-in, never in local mode. 5. Add to `~/.codeoid/.env`, then restart the daemon: ```bash - SLACK_APP_TOKEN=xapp-… - SLACK_BOT_TOKEN=xoxb-… - SLACK_ALLOWED_USER_IDS=U0123ABCD # comma-separated + CODEOID_SLACK_APP_TOKEN=xapp-… + CODEOID_SLACK_BOT_TOKEN=xoxb-… + CODEOID_SLACK_ALLOWED_USER_IDS=U0123ABCD # comma-separated + CODEOID_SLACK_TEAM_ID=T0123ABCD # optional: only this workspace ``` 6. In Slack, run `/codeoid auth ` once with your ZeroID key. A slash command isn't posted to the conversation, so the key doesn't stay in it. @@ -59,8 +60,9 @@ Slack works only with ZeroID sign-in, never in local mode. - Message the codeoid app to talk to the conductor. It is created the first time you write. - `/codeoid ls` lists your sessions; `/codeoid attach ` talks to one instead; `/codeoid conductor` goes back. - `/codeoid stop` interrupts the current turn; `/codeoid who` shows who you are signed in as. -- When the session you're talking to asks to run something, a card shows what it would do, the workstream it acts in, its risk and whether it is sandboxed, with Approve and Deny buttons. Only you can press your cards. +- When the session you're talking to asks to run something, a card shows what it would do, the workstream it acts in, its risk and whether it is sandboxed, with Approve and Deny buttons. Only the person a card was sent to can press it, and only once; a card answered elsewhere (the web UI, the CLI) closes. - When another of your sessions is waiting on you, you get a message naming it. -Only the people in `SLACK_ALLOWED_USER_IDS` can use it, and each acts with their own key: what they can do is what that key allows. +Only the people in `CODEOID_SLACK_ALLOWED_USER_IDS` can use it, and each acts with their own key: what they can do is what that key allows. Your sign-in ends when the key's token expires; run `/codeoid auth` again. +A key or token pasted into the DM by mistake is never sent on; revoke it, and delete the message. diff --git a/packages/protocol/src/approvals.ts b/packages/protocol/src/approvals.ts index b85ed6a8..f3fda9ca 100644 --- a/packages/protocol/src/approvals.ts +++ b/packages/protocol/src/approvals.ts @@ -42,6 +42,8 @@ export interface PendingToolApproval { sandboxNote?: string; /** The sandbox lets commands reach the container runtime (Docker access). */ sandboxDocker?: boolean; + /** The tool call's message: its later state updates (answered, cancelled) reference it. */ + messageId?: string; /** A send-class fleet call's workstream, and what approving it confirms (workstreams §10; as on the card). */ targetWorkstream?: { workstream?: string; warning?: string }; } diff --git a/src/cli.ts b/src/cli.ts index 195f4823..64c099d4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -243,11 +243,11 @@ program } } - // Slack (enabled when SLACK_APP_TOKEN and SLACK_BOT_TOKEN are set; docs/slack.md) + // Slack (enabled when CODEOID_SLACK_APP_TOKEN and CODEOID_SLACK_BOT_TOKEN are set; docs/slack.md) if (opts.slack !== false) { - const appToken = process.env.SLACK_APP_TOKEN; - const botToken = process.env.SLACK_BOT_TOKEN; - const allowed = (process.env.SLACK_ALLOWED_USER_IDS ?? "") + const appToken = process.env.CODEOID_SLACK_APP_TOKEN; + const botToken = process.env.CODEOID_SLACK_BOT_TOKEN; + const allowed = (process.env.CODEOID_SLACK_ALLOWED_USER_IDS ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean); @@ -256,9 +256,10 @@ program console.warn("[codeoid] local mode: Slack frontend NOT started (it needs ZeroID identities). Run `codeoid login` and start without --local to use it."); } else if (appToken && botToken && allowed.length > 0) { const { SlackFrontend } = await import("./frontends/slack/index.js"); - daemon.use(new SlackFrontend(appToken, botToken, allowed)); + const teamId = process.env.CODEOID_SLACK_TEAM_ID?.trim(); + daemon.use(new SlackFrontend(appToken, botToken, allowed, teamId ? { teamId } : {})); } else if (appToken || botToken) { - console.log("[codeoid] Slack needs SLACK_APP_TOKEN, SLACK_BOT_TOKEN and SLACK_ALLOWED_USER_IDS — skipping Slack"); + console.log("[codeoid] Slack needs CODEOID_SLACK_APP_TOKEN, CODEOID_SLACK_BOT_TOKEN and CODEOID_SLACK_ALLOWED_USER_IDS — skipping Slack"); } } diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index dc48407e..8893dffa 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -638,8 +638,7 @@ export class SessionManager { * pipeline run drives phases on a live session; the phase resolves when that * session next reaches a resting status (see #statusObserver). */ #phaseWaiters = new Map void>(); - /** Stable observer identity — every Session reports status transitions here. */ - #needsOwnerListeners = new Set<(sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }) => void>(); + #needsOwnerListeners = new Set<(sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }) => void>(); /** * Hear when a session starts waiting on its owner (an approval): its id, @@ -653,6 +652,7 @@ export class SessionManager { }; } +/** Stable observer identity — every Session reports status transitions here. */ #statusObserver = (sessionId: string, status: SessionInfo["status"], opts?: { settled: true }): void => { // A turn settled without a status change: only what waits on spend cares (#430). if (opts?.settled) { diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 9a7ff8e4..34e68d53 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -6530,8 +6530,10 @@ export class Session { if (!gated) continue; const risk = this.#shellRisk(gated.toolName, gated.input as Record, gated.shellCwd); const ws = this.#approvalWorkstreams.get(approvalId); + const messageId = this.#approvalIdToMessageId.get(approvalId); out.push({ approvalId, + ...(messageId ? { messageId } : {}), toolName: gated.toolName, input: gated.input, ...this.#cardRisk(risk, this.#heldWrite(gated.toolName, gated.input)), diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts index 3b636e18..dc55998d 100644 --- a/src/daemon/settings/manifest.ts +++ b/src/daemon/settings/manifest.ts @@ -805,6 +805,19 @@ const frontends: SettingsTab = { }), ], }, + { + id: "slack", + title: "Slack", + description: "Set the app and bot tokens and an allowlist to run the Slack frontend (docs/slack.md).", + fields: [ + secret("CODEOID_SLACK_APP_TOKEN", "App token", "App-level token (xapp-…) with connections:write: the Socket Mode connection."), + secret("CODEOID_SLACK_BOT_TOKEN", "Bot token", "Bot User OAuth Token (xoxb-…) the app posts with."), + env("CODEOID_SLACK_ALLOWED_USER_IDS", "Allowed member ids", "Slack member ids permitted to use the app (required with the tokens).", { + kind: "string[]", + }), + env("CODEOID_SLACK_TEAM_ID", "Workspace id", "Only messages from this Slack workspace (T…) are taken. Unset: the workspace the app is installed in.", {}), + ], + }, { id: "web", title: "Web", diff --git a/src/frontends/slack/index.ts b/src/frontends/slack/index.ts index fb369cb9..db1120d9 100644 --- a/src/frontends/slack/index.ts +++ b/src/frontends/slack/index.ts @@ -2,8 +2,8 @@ * Slack frontend: talk to codeoid's conductor from a Slack DM. * * Runs inside the daemon, over Socket Mode (socket.ts), so it needs no public - * URL. Enabled when SLACK_APP_TOKEN, SLACK_BOT_TOKEN and - * SLACK_ALLOWED_USER_IDS are set; ZeroID only, never in local mode. + * URL. Enabled when CODEOID_SLACK_APP_TOKEN, CODEOID_SLACK_BOT_TOKEN and + * CODEOID_SLACK_ALLOWED_USER_IDS are set; ZeroID only, never in local mode. * * - `/codeoid auth `: sign in with your own ZeroID key. A slash * command is never posted to the conversation, so the key isn't left in it. @@ -55,6 +55,8 @@ interface UserState { streams: Map; /** Posts to this user, in order. */ outbox: Promise; + /** Bumped when the sign-in changes: work queued under an earlier one is dropped, never done under this. */ + epoch: number; } const APPROVAL_TTL_MS = 60 * 60 * 1000; @@ -66,6 +68,8 @@ export interface SlackFrontendOptions { /** Transport for Slack (tests inject one). */ transport?: SlackTransport; approvalTtlMs?: number; + /** Only this Slack workspace's events (CODEOID_SLACK_TEAM_ID); unset: any the app receives. */ + teamId?: string; } export class SlackFrontend implements Frontend { @@ -76,7 +80,13 @@ export class SlackFrontend implements Frontend { #allowed: Set; #transport: SlackTransport | undefined; #approvalTtlMs: number; + #teamId: string | undefined; #socket: SlackSocket | null = null; + /** When each non-allowlisted user was last told so, and each session's last "needs you": neither repeated often. */ + #toldStranger = new Map(); + #toldNeeds = new Map(); + /** A card's tool-call message → its approval key, to close the card when the call is answered elsewhere. */ + #cardOfMessage = new Map(); #manager!: SessionManager; #authConfig!: AuthConfig; #verifier!: TokenVerifier; @@ -91,6 +101,7 @@ export class SlackFrontend implements Frontend { this.#allowed = new Set(allowedUserIds); this.#transport = opts.transport; this.#approvalTtlMs = opts.approvalTtlMs ?? APPROVAL_TTL_MS; + this.#teamId = opts.teamId; } async start(ctx: FrontendContext): Promise { @@ -102,9 +113,10 @@ export class SlackFrontend implements Frontend { if (ctx.verifier.mode !== "zeroid") { throw new Error("the Slack frontend requires ZeroID auth — it is not available in local mode (run `codeoid login`, then start without --local)"); } - this.#unsubscribe = this.#manager.onNeedsOwner((sessionId, name, owner) => this.#needsYou(sessionId, name, owner)); this.#socket = new SlackSocket(this.#appToken, (env) => this.handleEnvelope(env), this.#transport); await this.#socket.start(); + // Only once connected: a start that fails leaves nothing subscribed. + this.#unsubscribe = this.#manager.onNeedsOwner((sessionId, name, owner) => this.#needsYou(sessionId, name, owner)); } async stop(): Promise { @@ -119,6 +131,11 @@ export class SlackFrontend implements Frontend { /** One Socket Mode envelope; what it returns is the ack's payload (a slash command's ephemeral reply). Public for tests. */ async handleEnvelope(env: SlackEnvelope): Promise | undefined> { + if (this.#teamId) { + const p = env.payload; + const team = p.team_id ?? (p.team as { id?: unknown } | undefined)?.id ?? (p.event as { team?: unknown } | undefined)?.team; + if (team !== this.#teamId) return undefined; + } if (env.type === "slash_commands") return this.#slashCommand(env.payload); if (env.type === "events_api") { const event = env.payload.event as Record | undefined; @@ -190,6 +207,13 @@ export class SlackFrontend implements Frontend { state.auth = auth; state.rawToken = token; this.#store.audit(auth.sub, "slack.auth", undefined, `slack_user_id=${userId}${auth.name ? ` name=${auth.name}` : ""}`); + // Their DM with the app, so replies have somewhere to go before they first write there. + if (!state.dmChannel) { + try { + const opened = await slackApi<{ channel: { id: string } }>(this.#transportOrDefault(), this.#botToken, "conversations.open", { users: userId }); + state.dmChannel = opened.channel.id; + } catch {} + } return `Signed in as ${auth.name ?? auth.sub}. Message me to talk to the conductor.`; } catch (err) { return `Sign-in failed: ${err instanceof Error ? err.message : "unknown error"}`; @@ -252,6 +276,21 @@ export class SlackFrontend implements Frontend { state.attachedName = prev.name; return `Couldn't attach: ${resp.error}`; } + // What it was already waiting on was said before this attach: shown now, as cards. + if (state.dmChannel) { + const approvals = await this.#manager.handle({ type: "session.approvals", id: randomUUID(), sessionId }, state.auth!, this.#client(userId, state)); + const pending = (approvals as { approvals?: Array<{ approvalId: string; messageId?: string; toolName: string; input: unknown; risk?: unknown; sandboxed?: boolean; sandboxNote?: string; sandboxDocker?: boolean; targetWorkstream?: { workstream?: string; warning?: string } }> }).approvals ?? []; + const epoch = state.epoch; + const channel = state.dmChannel; + for (const a of pending) { + const tool = { + name: a.toolName, + toolId: a.approvalId, + state: { phase: "waiting_confirmation", approvalId: a.approvalId, input: a.input, description: `${a.toolName} ${JSON.stringify(a.input).slice(0, 1_500)}`, risk: a.risk, sandboxed: a.sandboxed, sandboxNote: a.sandboxNote, sandboxDocker: a.sandboxDocker, targetWorkstream: a.targetWorkstream }, + } as unknown as ToolInfo; + this.#queue(userId, () => (state.epoch === epoch ? this.#approvalCard(userId, state, tool, sessionId, channel, a.messageId) : Promise.resolve())); + } + } return `Talking to *${esc(name)}* now.`; } @@ -273,7 +312,17 @@ export class SlackFrontend implements Frontend { const text = String(event.text ?? "").trim(); if (!userId || !channel || !text) return; if (!this.#allowed.has(userId)) { - await this.#post(channel, "You're not on this codeoid's allowlist."); + const last = this.#toldStranger.get(userId) ?? 0; + if (Date.now() - last > 60 * 60 * 1000) { + this.#toldStranger.set(userId, Date.now()); + if (this.#toldStranger.size > 1000) this.#toldStranger.clear(); + await this.#post(channel, "You're not on this codeoid's allowlist."); + } + return; + } + // A key or a token pasted here would reach the model and its transcript: never forwarded. + if (looksLikeSecret(text)) { + await this.#post(channel, "⚠️ That looks like a key or a token, so it wasn't sent. Revoke it, and delete your message (I can't). Sign in with `/codeoid auth `: a slash command isn't kept in the conversation."); return; } const state = this.#state(userId); @@ -318,28 +367,43 @@ export class SlackFrontend implements Frontend { // hasn't been posted is posted at the turn's end. if (m.content) { state.streams.delete(m.messageId); - this.#say(userId, channel, m.content); + this.#say(userId, channel, esc(m.content)); } else if (!state.streams.has(m.messageId)) state.streams.set(m.messageId, ""); } else if (m.role === "tool_call" && m.tool?.state.phase === "waiting_confirmation" && "approvalId" in m.tool.state) { const tool = m.tool; - this.#queue(userId, () => this.#approvalCard(userId, state, tool)); + // Bound now: the session it came from and the sign-in it came to (H1). + const epoch = state.epoch; + const sessionId = m.sessionId; + const messageId = m.messageId; + this.#queue(userId, () => (state.epoch === epoch ? this.#approvalCard(userId, state, tool, sessionId, channel, messageId) : Promise.resolve())); } else if (m.role === "system" && m.content) { - this.#say(userId, channel, `⚠️ ${m.content}`); + this.#say(userId, channel, `⚠️ ${esc(m.content)}`); } return; } if (msg.type === "session.message.delta") { if (msg.contentAppend && state.streams.has(msg.messageId)) state.streams.set(msg.messageId, (state.streams.get(msg.messageId) ?? "") + msg.contentAppend); + // A card's call answered elsewhere (the web UI, the CLI) or cancelled: its buttons go. + const phase = msg.toolStateUpdate?.phase; + const key = this.#cardOfMessage.get(msg.messageId); + if (key && phase && phase !== "waiting_confirmation") { + this.#cardOfMessage.delete(msg.messageId); + const card = this.#approvals.get(key); + if (card) { + this.#approvals.delete(key); + if (card.ts) this.#queue(userId, () => this.#update(card.channel, card.ts!, "Answered elsewhere.")); + } + } return; } if (msg.type === "session.status_change" && (msg.status === "idle" || msg.status === "error")) { - for (const text of state.streams.values()) if (text.trim()) this.#say(userId, channel, text); + for (const text of state.streams.values()) if (text.trim()) this.#say(userId, channel, esc(text)); state.streams.clear(); if (msg.status === "error") this.#say(userId, channel, "❌ The turn ended with an error."); return; } if (msg.type === "session.ui_request") { - this.#say(userId, channel, `❓ ${msg.title} — answer it in the web UI or with \`codeoid attach\`.`); + this.#say(userId, channel, `❓ ${esc(msg.title)} — answer it in the web UI or with \`codeoid attach\`.`); } } @@ -356,15 +420,24 @@ export class SlackFrontend implements Frontend { // ── Approval cards ──────────────────────────────────────────────────── - async #approvalCard(userId: string, state: UserState, tool: ToolInfo): Promise { + async #approvalCard(userId: string, state: UserState, tool: ToolInfo, sessionId: string, channel: string, messageId?: string): Promise { const st = tool.state; - if (st.phase !== "waiting_confirmation") return; + if (st.phase !== "waiting_confirmation" || !state.auth) return; + // A question for you (AskUserQuestion) needs its options, which a card here can't take: say where to answer. + if (tool.name === "AskUserQuestion") { + await this.#post(channel, "❓ A question is waiting for you — answer it in the web UI or with `codeoid attach`."); + return; + } this.#prune(); const key = `${userId}:${st.approvalId}`; if (this.#approvals.has(key)) return; - const pending: PendingApproval = { approvalId: st.approvalId, sessionId: state.attachedSessionId!, userId, channel: state.dmChannel!, createdAt: Date.now() }; + const pending: PendingApproval = { approvalId: st.approvalId, sessionId, userId, channel, createdAt: Date.now() }; this.#approvals.set(key, pending); - const lines = [`*Approval needed* — \`${esc(tool.name)}\``, esc(String(st.description ?? tool.name)).slice(0, 2_000)]; + if (messageId) this.#cardOfMessage.set(messageId, key); + const full = esc(String(st.description ?? tool.name)); + // Never cut silently: a tail it doesn't show could be what runs. + const shown = full.length > 2_000 ? `${full.slice(0, 2_000)}…\n_(cut short: see the whole call in the web UI or with \`codeoid approve\`)_` : full; + const lines = [`*Approval needed* — \`${esc(tool.name)}\``, shown]; const tw = st.targetWorkstream; if (tw?.workstream) lines.push(`⌂ workstream *${esc(tw.workstream)}*`); if (tw?.warning) lines.push(`⚠ ${esc(tw.warning)}`); @@ -378,6 +451,8 @@ export class SlackFrontend implements Frontend { const res = await slackApi<{ ts: string }>(this.#transportOrDefault(), this.#botToken, "chat.postMessage", { channel: pending.channel, text: `Approval needed — ${tool.name}`, + unfurl_links: false, + unfurl_media: false, blocks: [ { type: "section", text: { type: "mrkdwn", text: text.slice(0, CHUNK) } }, { @@ -454,6 +529,10 @@ export class SlackFrontend implements Frontend { if (a.sub !== owner.sub || a.accountId !== owner.accountId || a.projectId !== owner.projectId) continue; // The attached session's cards come with the conversation. if (state.attachedSessionId === sessionId) continue; + const told = `${userId}:${sessionId}`; + if (Date.now() - (this.#toldNeeds.get(told) ?? 0) < 10 * 60 * 1000) continue; + this.#toldNeeds.set(told, Date.now()); + if (this.#toldNeeds.size > 1000) this.#toldNeeds.clear(); this.#queue(userId, () => this.#post(channel, `🔔 *${esc(name)}* is waiting for you. \`/codeoid attach ${esc(name)}\` to answer it here.`)); } } @@ -463,7 +542,7 @@ export class SlackFrontend implements Frontend { #state(userId: string): UserState { let s = this.#users.get(userId); if (!s) { - s = { auth: null, rawToken: null, attachedSessionId: null, attachedName: null, clientId: `slack:${userId}`, dmChannel: null, streams: new Map(), outbox: Promise.resolve() }; + s = { auth: null, rawToken: null, attachedSessionId: null, attachedName: null, clientId: `slack:${userId}`, dmChannel: null, streams: new Map(), outbox: Promise.resolve(), epoch: 0 }; this.#users.set(userId, s); } return s; @@ -487,6 +566,7 @@ export class SlackFrontend implements Frontend { /** Let go of a user's identity, attachment and pending cards: a card is never answered under another sign-in. */ #forget(userId: string, state: UserState): void { + state.epoch++; state.auth = null; state.rawToken = null; try { @@ -509,7 +589,7 @@ export class SlackFrontend implements Frontend { async #post(channel: string, text: string): Promise { for (let i = 0; i < text.length || i === 0; i += CHUNK) { try { - await slackApi(this.#transportOrDefault(), this.#botToken, "chat.postMessage", { channel, text: text.slice(i, i + CHUNK), mrkdwn: true }); + await slackApi(this.#transportOrDefault(), this.#botToken, "chat.postMessage", { channel, text: text.slice(i, i + CHUNK), mrkdwn: true, unfurl_links: false, unfurl_media: false }); } catch (err) { console.error(`[codeoid:slack] post failed: ${err instanceof Error ? err.message : String(err)}`); return; @@ -530,3 +610,11 @@ export class SlackFrontend implements Frontend { export function esc(text: string): string { return text.replace(/&/g, "&").replace(//g, ">"); } + +/** + * Whether a DM looks like a credential pasted by mistake: a ZeroID key, a + * JWT, or a Slack token. It is never forwarded to a session. + */ +export function looksLikeSecret(text: string): boolean { + return /\bzid_sk_[A-Za-z0-9_-]{8,}/.test(text) || /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/.test(text) || /\bxox[abposr]-[A-Za-z0-9-]{8,}|\bxapp-[A-Za-z0-9-]{8,}/.test(text); +} diff --git a/src/frontends/slack/socket.ts b/src/frontends/slack/socket.ts index 58bfa199..67c46d2e 100644 --- a/src/frontends/slack/socket.ts +++ b/src/frontends/slack/socket.ts @@ -65,6 +65,8 @@ export async function slackApi>( method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", Authorization: `Bearer ${token}` }, body: JSON.stringify(body), + // A call that hangs must not hold up everything queued behind it. + signal: AbortSignal.timeout(15_000), }); if (res.status === 429 && attempt === 0) { const wait = Math.min(30, Number(res.headers.get("retry-after")) || 1); @@ -77,10 +79,20 @@ export async function slackApi>( } } +/** How long a slash command's handler has to answer inside the ack; a slower answer goes to its response_url. */ +const SLASH_ACK_MS = 2_500; +/** Event ids remembered to drop Slack's retries of what was already handled. */ +const SEEN_EVENTS = 500; + /** * One Socket Mode connection, kept open: reconnects when Slack sends a * `disconnect` (it refreshes connections every few hours) or the socket * drops, backing off from 1 s to 30 s while it can't connect. + * + * Slack resends an envelope not acknowledged within 3 s, so events and + * button presses are acknowledged at once and handled after, one at a time + * in arrival order, and an event Slack retries is dropped. A slash command + * answers inside its ack when it can, else at its response_url. */ export class SlackSocket { #appToken: string; @@ -90,6 +102,8 @@ export class SlackSocket { #stopped = false; #backoffMs = 1_000; #timer: ReturnType | null = null; + #inbound: Promise = Promise.resolve(); + #seen = new Set(); constructor( appToken: string, @@ -101,7 +115,7 @@ export class SlackSocket { this.#transport = transport; } - /** Open the connection; resolves once the first one is open, or throws if Slack refuses the app token. */ + /** Ask Slack for a connection and open it; throws if Slack refuses the app token. Reconnects on its own after. */ async start(): Promise { this.#stopped = false; await this.#open(); @@ -119,16 +133,23 @@ export class SlackSocket { async #open(): Promise { const { url } = await slackApi<{ url: string }>(this.#transport, this.#appToken, "apps.connections.open", {}); if (this.#stopped) return; + const previous = this.#socket; const socket = this.#transport.connect(url); this.#socket = socket; - socket.onopen = () => { - this.#backoffMs = 1_000; - }; - socket.onmessage = (ev) => void this.#receive(socket, ev.data); + socket.onmessage = (ev) => this.#receive(socket, ev.data); socket.onerror = () => {}; socket.onclose = () => { - if (this.#socket === socket) this.#socket = null; - this.#reconnect(); + if (this.#socket === socket) { + this.#socket = null; + this.#reconnect(); + } + }; + // The one being replaced goes only once this one is up, so nothing in between is lost. + socket.onopen = () => { + if (previous && previous !== socket) { + previous.onclose = null; + previous.close(); + } }; } @@ -145,21 +166,21 @@ export class SlackSocket { }, wait); } - async #receive(socket: SocketLike, data: unknown): Promise { + #receive(socket: SocketLike, data: unknown): void { let msg: { type?: string; envelope_id?: string; payload?: unknown; accepts_response_payload?: boolean }; try { msg = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer)); } catch { return; } - if (msg.type === "hello") return; + if (msg.type === "hello") { + // Connected for real: the next drop starts backing off afresh. + this.#backoffMs = 1_000; + return; + } if (msg.type === "disconnect") { - // Slack is about to close this connection: open the next one now, then let this one go. - const old = socket; - if (this.#socket === old) this.#socket = null; - old.onclose = null; - old.close(); - this.#reconnect(); + // Slack is about to close this one: open the next, which closes this when it is up. + this.#open().catch(() => this.#reconnect()); return; } if (!msg.envelope_id || !msg.payload || typeof msg.payload !== "object") return; @@ -169,16 +190,56 @@ export class SlackSocket { payload: msg.payload as Record, ...(msg.accepts_response_payload ? { accepts_response_payload: true } : {}), }; - let response: Record | undefined; + const ack = (payload?: Record) => { + try { + socket.send(JSON.stringify({ envelope_id: env.envelope_id, ...(payload && env.accepts_response_payload ? { payload } : {}) })); + } catch { + // Closed meanwhile: Slack resends it on the next connection (and a resent event is dropped below). + } + }; + if (env.type === "slash_commands") { + void this.#slash(env, ack); + return; + } + ack(); + const eventId = typeof env.payload.event_id === "string" ? env.payload.event_id : undefined; + if (eventId) { + if (this.#seen.has(eventId)) return; + this.#seen.add(eventId); + if (this.#seen.size > SEEN_EVENTS) this.#seen.delete(this.#seen.values().next().value!); + } + this.#inbound = this.#inbound.then(async () => { + await this.#handle(env); + }); + } + + async #handle(env: SlackEnvelope): Promise | undefined> { try { - response = await this.#onEnvelope(env); + return await this.#onEnvelope(env); } catch (err) { console.error(`[codeoid:slack] handling ${env.type} failed: ${err instanceof Error ? err.message : String(err)}`); + return undefined; } - try { - socket.send(JSON.stringify({ envelope_id: env.envelope_id, ...(response && env.accepts_response_payload ? { payload: response } : {}) })); - } catch { - // The socket closed meanwhile: Slack retries the envelope on the next connection. + } + + /** A slash command: its answer in the ack if it comes within SLASH_ACK_MS, else posted to its response_url. */ + async #slash(env: SlackEnvelope, ack: (payload?: Record) => void): Promise { + let late = false; + const timer = new Promise<"late">((r) => setTimeout(() => r("late"), SLASH_ACK_MS)); + const work = this.#handle(env); + const first = await Promise.race([work, timer]); + if (first !== "late") { + ack(first); + return; + } + late = true; + ack(); + const answer = await work; + const url = typeof env.payload.response_url === "string" ? env.payload.response_url : undefined; + if (late && answer && url?.startsWith("https://hooks.slack.com/")) { + await this.#transport + .fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(answer), signal: AbortSignal.timeout(15_000) }) + .catch(() => {}); } } } diff --git a/src/tests/slack-frontend.test.ts b/src/tests/slack-frontend.test.ts index 3ba824f0..49e8b6e7 100644 --- a/src/tests/slack-frontend.test.ts +++ b/src/tests/slack-frontend.test.ts @@ -15,7 +15,7 @@ import { SessionManager } from "../daemon/session-manager.js"; import { Store } from "../daemon/store.js"; import { TranscriptStore } from "../daemon/transcript.js"; import type { TokenVerifier } from "../daemon/verifier.js"; -import { SlackFrontend, esc } from "../frontends/slack/index.js"; +import { SlackFrontend, esc, looksLikeSecret } from "../frontends/slack/index.js"; import type { SlackTransport, SocketLike } from "../frontends/slack/socket.js"; import type { FrontendContext } from "../frontends/types.js"; import { ALL_SCOPES } from "../protocol/scopes.js"; @@ -47,6 +47,7 @@ function transport(): SlackTransport { const body = JSON.parse(String(init?.body ?? "{}")) as Record; calls.push({ method, body }); if (method === "apps.connections.open") return Response.json({ ok: true, url: "wss://test" }); + if (method === "conversations.open") return Response.json({ ok: true, channel: { id: `D-${String(body.users)}` } }); return Response.json({ ok: true, ts: `${calls.length}.0` }); }, connect: () => ({ send() {}, close() {}, onopen: null, onmessage: null, onclose: null, onerror: null }) as SocketLike, @@ -205,3 +206,67 @@ describe("text", () => { expect(esc(" & ")).toBe("<!channel> & <https://x|y>"); }); }); + +describe("audit round 1", () => { + it("a key pasted into the DM is never sent on", async () => { + await slash(OWNER, "auth tok-owner"); + await dm(OWNER, "here it is zid_sk_live_SECRET1234567890"); + await until(() => posts().some((t) => t.includes("looks like a key"))); + expect(providers.length).toBe(0); + expect(looksLikeSecret("eyJhbGciOiJFUzI1.eyJzdWIiOiJ1c2Vy.c2lnbmF0dXJlMTI")).toBe(true); + expect(looksLikeSecret("xoxb-1234567890-abcdefgh")).toBe(true); + expect(looksLikeSecret("please fix the zid parsing")).toBe(false); + }); + + it("a card raised under one sign-in is never answered under another", async () => { + script = [[{ type: "tool_start", toolId: "t1", sdkToolUseId: "s1", name: "Write", input: { file_path: join(tmp, "a.txt"), content: "x" }, approvalId: "ap-h1" } as ProviderEvent, done]]; + await slash(OWNER, "auth tok-owner"); + await dm(OWNER, "write it"); + await until(() => calls.some((c) => Array.isArray(c.body.blocks))); + // The same Slack user signs in as someone else: the card they held is let go. + await slash(OWNER, "auth tok-other"); + await press(OWNER, "approve", "ap-h1"); + await until(() => calls.some((c) => c.method === "chat.update" && String(c.body.text).includes("no longer waiting"))); + expect(providers.at(-1)!.canUseToolResults.length).toBe(0); + }); + + it("attaching to a session already waiting shows its card; one answered elsewhere closes", async () => { + await slash(OWNER, "auth tok-owner"); + script = [[{ type: "tool_start", toolId: "t3", sdkToolUseId: "s3", name: "Write", input: { file_path: join(tmp, "c.txt"), content: "x" }, approvalId: "ap-m1" } as ProviderEvent, done]]; + await manager.handle({ type: "session.create", id: "c", name: "waiter", workdir: tmp } as never, AUTH["tok-owner"]!, { id: "cli", auth: AUTH["tok-owner"]!, send: () => {} }); + const session = manager.findByName("waiter", AUTH["tok-owner"]!)!; + void session.send("go", AUTH["tok-owner"]!); + await until(() => session.pendingToolApprovals().length > 0); + await slash(OWNER, "attach waiter"); + await until(() => calls.some((c) => Array.isArray(c.body.blocks) && JSON.stringify(c.body.blocks).includes("ap-m1"))); + // Answered from elsewhere (the CLI): the card's buttons go. + await manager.handle({ type: "session.approve", id: "a", sessionId: session.id, approvalId: "ap-m1", approved: false } as never, AUTH["tok-owner"]!, { id: "cli2", auth: AUTH["tok-owner"]!, send: () => {} }); + await until(() => calls.some((c) => c.method === "chat.update" && String(c.body.text).includes("Answered elsewhere"))); + }); + + it("model text is escaped and links don't unfurl", async () => { + script = [[{ type: "text_done", content: " look " } as ProviderEvent, done]]; + await slash(OWNER, "auth tok-owner"); + await dm(OWNER, "hi"); + await until(() => posts().some((t) => t.includes("<!channel>"))); + const post = calls.find((c) => String(c.body.text).includes("<!channel>"))!; + expect(post.body.unfurl_links).toBe(false); + expect(String(post.body.text)).not.toContain(""); + }); + + it("signing in opens the DM, so replies have somewhere to go before the first message", async () => { + await slash(OWNER, "auth tok-owner"); + expect(calls.some((c) => c.method === "conversations.open" && c.body.users === OWNER)).toBe(true); + }); +}); + +describe("one workspace", () => { + it("with a workspace id set, another workspace's events are ignored", async () => { + await frontend.stop(); + frontend = new SlackFrontend("xapp-test", "xoxb-test", [OWNER], { transport: transport(), teamId: "T1" }); + const verifier = { mode: "zeroid", verify: async (t: string) => AUTH[t] ?? Promise.reject(new Error("bad")) } as unknown as TokenVerifier; + await frontend.start({ manager, store, auth: { baseUrl: "http://localhost:1" }, verifier, httpServer: null as never, host: "127.0.0.1", port: 0 } as FrontendContext); + expect(await frontend.handleEnvelope({ envelope_id: "e", type: "slash_commands", payload: { team_id: "T2", user_id: OWNER, text: "auth tok-owner" } })).toBeUndefined(); + expect(await frontend.handleEnvelope({ envelope_id: "e", type: "slash_commands", payload: { team_id: "T1", user_id: OWNER, text: "auth tok-owner" } })).toMatchObject({ text: expect.stringContaining("Signed in") }); + }); +}); diff --git a/src/tests/slack-socket.test.ts b/src/tests/slack-socket.test.ts index 3a830ab0..8a89a074 100644 --- a/src/tests/slack-socket.test.ts +++ b/src/tests/slack-socket.test.ts @@ -40,7 +40,8 @@ describe("SlackSocket", () => { s.onmessage?.({ data: JSON.stringify({ envelope_id: "e2", type: "events_api", payload: { event: {} } }) }); await Bun.sleep(10); expect(seen).toEqual(["slash_commands", "events_api"]); - expect(s.sent.map((x) => JSON.parse(x))).toEqual([{ envelope_id: "e1", payload: { text: "hi" } }, { envelope_id: "e2" }]); + // An event is acknowledged at once; the slash command once its answer is ready, with it. + expect(s.sent.map((x) => JSON.parse(x)).sort((a, b) => a.envelope_id.localeCompare(b.envelope_id))).toEqual([{ envelope_id: "e1", payload: { text: "hi" } }, { envelope_id: "e2" }]); socket.stop(); }); @@ -58,4 +59,55 @@ describe("SlackSocket", () => { const slack = fakeSlack(); await expect(slackApi(slack.transport, "xoxb", "chat.postMessage", {})).rejects.toBeInstanceOf(SlackApiError); }); + + it("events are acknowledged at once, handled one at a time in order, and a retried event is dropped", async () => { + const slack = fakeSlack(); + const order: string[] = []; + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + const socket = new SlackSocket("xapp", async (env) => { + const id = String(env.payload.event_id); + if (id === "Ev1") await gate; + order.push(id); + return undefined; + }, slack.transport); + await socket.start(); + const s = slack.sockets[0]!; + s.onmessage?.({ data: JSON.stringify({ envelope_id: "e1", type: "events_api", payload: { event_id: "Ev1" } }) }); + s.onmessage?.({ data: JSON.stringify({ envelope_id: "e2", type: "events_api", payload: { event_id: "Ev2" } }) }); + s.onmessage?.({ data: JSON.stringify({ envelope_id: "e3", type: "events_api", payload: { event_id: "Ev1" }, retry_attempt: 1 }) }); + await Bun.sleep(10); + // All three acknowledged before any handling finished. + expect(s.sent.length).toBe(3); + release(); + await Bun.sleep(20); + expect(order).toEqual(["Ev1", "Ev2"]); + socket.stop(); + }); + + it("a slow slash command answers at its response_url", async () => { + const slack = fakeSlack(); + const posted: string[] = []; + const fetch = slack.transport.fetch; + slack.transport.fetch = async (url, init) => { + if (String(url).startsWith("https://hooks.slack.com/")) { + posted.push(String(init?.body)); + return new Response("ok"); + } + return fetch(url, init); + }; + const socket = new SlackSocket("xapp", async () => { + await Bun.sleep(2_700); + return { text: "late answer" }; + }, slack.transport); + await socket.start(); + const s = slack.sockets[0]!; + s.onmessage?.({ data: JSON.stringify({ envelope_id: "e1", type: "slash_commands", accepts_response_payload: true, payload: { response_url: "https://hooks.slack.com/commands/x" } }) }); + await Bun.sleep(3_000); + expect(JSON.parse(s.sent[0]!)).toEqual({ envelope_id: "e1" }); + expect(posted).toEqual([JSON.stringify({ text: "late answer" })]); + socket.stop(); + }); }); From d304b19b23fedc60a38664520962293730b35c5c Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Fri, 13 Nov 2026 16:40:58 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20Slack=20S1=20audit=20round=202=20?= =?UTF-8?q?=E2=80=94=20a=20card=20shows=20the=20call=20it=20asks=20about;?= =?UTF-8?q?=20one=20user's=20handling=20never=20blocks=20another's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cards show the command, the file and what it writes, or the plan, from the call's input (the description only names the arguments), split across sections and cut only with a note; the same on the attach path - one inbound queue per Slack user, each envelope bounded at 60 s - resent presses and commands dropped like events; a repeated press says nothing more; every live socket closed on stop; a start that can't reach Slack retries, a refused app token stops - the pasted-key check reuses the known key formats (redact.ts KNOWN_KEY_FORMATS) plus JWTs and private keys - a card answered before it was posted isn't posted; cards close for every Slack user holding them; their bookkeeping is let go with them - a DM re-checks the sign-in after each wait; a DM that can't be opened at sign-in is said Co-Authored-By: Claude Opus 5.5 --- docs/slack.md | 2 +- src/daemon/mcp/redact.ts | 9 ++- src/daemon/session-manager.ts | 4 +- src/daemon/settings/manifest.ts | 2 +- src/frontends/slack/index.ts | 124 +++++++++++++++++++++++++------ src/frontends/slack/socket.ts | 94 ++++++++++++++++++----- src/tests/cli-approve.test.ts | 2 +- src/tests/slack-frontend.test.ts | 43 ++++++++++- src/tests/slack-socket.test.ts | 77 +++++++++++++++++++ 9 files changed, 303 insertions(+), 54 deletions(-) diff --git a/docs/slack.md b/docs/slack.md index f42bbd4e..fdc29446 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -61,7 +61,7 @@ Slack works only with ZeroID sign-in, never in local mode. - `/codeoid ls` lists your sessions; `/codeoid attach ` talks to one instead; `/codeoid conductor` goes back. - `/codeoid stop` interrupts the current turn; `/codeoid who` shows who you are signed in as. - When the session you're talking to asks to run something, a card shows what it would do, the workstream it acts in, its risk and whether it is sandboxed, with Approve and Deny buttons. Only the person a card was sent to can press it, and only once; a card answered elsewhere (the web UI, the CLI) closes. -- When another of your sessions is waiting on you, you get a message naming it. +- When another of your sessions is waiting on you, you get a message naming it (at most once every ten minutes per session). Only the people in `CODEOID_SLACK_ALLOWED_USER_IDS` can use it, and each acts with their own key: what they can do is what that key allows. Your sign-in ends when the key's token expires; run `/codeoid auth` again. diff --git a/src/daemon/mcp/redact.ts b/src/daemon/mcp/redact.ts index 8b7d699d..3e51d623 100644 --- a/src/daemon/mcp/redact.ts +++ b/src/daemon/mcp/redact.ts @@ -32,6 +32,10 @@ const URL_CHARS = 65_536; * and — as a backstop for what those don't know — a long run of token * characters mixing letters and digits. Bounded. */ +/** Well-known credential formats (codeoid's own ZeroID keys first), as a regex alternation: redacted here, refused where a key must never be sent on (the Slack DM). */ +export const KNOWN_KEY_FORMATS = + "zid_sk_\\w{8,}|hf_(?:sk_)?\\w{8,}|sk-[\\w-]{8,}|sk_[a-z]+_\\w{8,}|gh[pousr]_\\w{8,}|github_pat_\\w{8,}|glpat-[\\w-]{16,}|npm_\\w{30,}|xox[abprs]-[\\w-]{8,}|xapp-[\\w-]{8,}|AKIA[0-9A-Z]{16}|AIza[\\w-]{20,}"; + export function redactSecretText(text: string): string { const cut = text.length > SCAN_CHARS; const s = (cut ? text.slice(0, SCAN_CHARS) : text) @@ -58,10 +62,7 @@ export function redactSecretText(text: string): string { "$1$2$3…", ) // well-known key formats (codeoid's own ZeroID keys first) - .replace( - /\b(?:zid_sk_\w{8,}|hf_(?:sk_)?\w{8,}|sk-[\w-]{8,}|sk_[a-z]+_\w{8,}|gh[pousr]_\w{8,}|github_pat_\w{8,}|glpat-[\w-]{16,}|npm_\w{30,}|xox[abprs]-[\w-]{8,}|AKIA[0-9A-Z]{16}|AIza[\w-]{20,})/g, - "…", - ) + .replace(new RegExp(`\\b(?:${KNOWN_KEY_FORMATS})`, "g"), "…") // long token-like path segments, then query strings and fragments .replace(/(?:]|$)/g, "/…") .replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#"']+)[?#][^\s"'),;\]>]*/gi, "$1?…") diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 8893dffa..5f0a02c6 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -638,7 +638,7 @@ export class SessionManager { * pipeline run drives phases on a live session; the phase resolves when that * session next reaches a resting status (see #statusObserver). */ #phaseWaiters = new Map void>(); - #needsOwnerListeners = new Set<(sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }) => void>(); + #needsOwnerListeners = new Set<(sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }) => void>(); /** * Hear when a session starts waiting on its owner (an approval): its id, @@ -652,7 +652,7 @@ export class SessionManager { }; } -/** Stable observer identity — every Session reports status transitions here. */ + /** Stable observer identity — every Session reports status transitions here. */ #statusObserver = (sessionId: string, status: SessionInfo["status"], opts?: { settled: true }): void => { // A turn settled without a status change: only what waits on spend cares (#430). if (opts?.settled) { diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts index dc55998d..131a0349 100644 --- a/src/daemon/settings/manifest.ts +++ b/src/daemon/settings/manifest.ts @@ -815,7 +815,7 @@ const frontends: SettingsTab = { env("CODEOID_SLACK_ALLOWED_USER_IDS", "Allowed member ids", "Slack member ids permitted to use the app (required with the tokens).", { kind: "string[]", }), - env("CODEOID_SLACK_TEAM_ID", "Workspace id", "Only messages from this Slack workspace (T…) are taken. Unset: the workspace the app is installed in.", {}), + env("CODEOID_SLACK_TEAM_ID", "Workspace id", "Only messages from this Slack workspace (T…) are taken. Unset: any workspace the app receives from.", {}), ], }, { diff --git a/src/frontends/slack/index.ts b/src/frontends/slack/index.ts index db1120d9..67a8d71b 100644 --- a/src/frontends/slack/index.ts +++ b/src/frontends/slack/index.ts @@ -30,6 +30,7 @@ import type { SessionManager } from "../../daemon/session-manager.js"; import type { Store } from "../../daemon/store.js"; import type { TokenVerifier } from "../../daemon/verifier.js"; import type { Frontend, FrontendContext } from "../types.js"; +import { KNOWN_KEY_FORMATS } from "../../daemon/mcp/redact.js"; import { defaultTransport, type SlackEnvelope, SlackSocket, type SlackTransport, slackApi } from "./socket.js"; /** An approval card awaiting a button press, keyed `${slackUserId}:${approvalId}`. */ @@ -39,6 +40,8 @@ interface PendingApproval { userId: string; channel: string; ts?: string; + /** The tool call's message, whose later updates close the card. */ + messageId?: string; createdAt: number; } @@ -85,8 +88,12 @@ export class SlackFrontend implements Frontend { /** When each non-allowlisted user was last told so, and each session's last "needs you": neither repeated often. */ #toldStranger = new Map(); #toldNeeds = new Map(); - /** A card's tool-call message → its approval key, to close the card when the call is answered elsewhere. */ - #cardOfMessage = new Map(); + /** A card's tool-call message → its cards' approval keys (one per Slack user holding it), to close them when answered elsewhere. */ + #cardOfMessage = new Map>(); + /** Tool-call messages already answered: a card queued for one isn't posted. */ + #answered = new Set(); + /** Cards just answered here: a repeated press says nothing more. */ + #justAnswered = new Set(); #manager!: SessionManager; #authConfig!: AuthConfig; #verifier!: TokenVerifier; @@ -214,6 +221,7 @@ export class SlackFrontend implements Frontend { state.dmChannel = opened.channel.id; } catch {} } + if (!state.dmChannel) return `Signed in as ${auth.name ?? auth.sub}. Message me once so I can reply to you (I couldn't open our DM).`; return `Signed in as ${auth.name ?? auth.sub}. Message me to talk to the conductor.`; } catch (err) { return `Sign-in failed: ${err instanceof Error ? err.message : "unknown error"}`; @@ -286,7 +294,7 @@ export class SlackFrontend implements Frontend { const tool = { name: a.toolName, toolId: a.approvalId, - state: { phase: "waiting_confirmation", approvalId: a.approvalId, input: a.input, description: `${a.toolName} ${JSON.stringify(a.input).slice(0, 1_500)}`, risk: a.risk, sandboxed: a.sandboxed, sandboxNote: a.sandboxNote, sandboxDocker: a.sandboxDocker, targetWorkstream: a.targetWorkstream }, + state: { phase: "waiting_confirmation", approvalId: a.approvalId, input: a.input, description: a.toolName, risk: a.risk, sandboxed: a.sandboxed, sandboxNote: a.sandboxNote, sandboxDocker: a.sandboxDocker, targetWorkstream: a.targetWorkstream }, } as unknown as ToolInfo; this.#queue(userId, () => (state.epoch === epoch ? this.#approvalCard(userId, state, tool, sessionId, channel, a.messageId) : Promise.resolve())); } @@ -332,6 +340,7 @@ export class SlackFrontend implements Frontend { await this.#post(channel, authed); return; } + const epoch = state.epoch; if (!state.attachedSessionId) { const id = await this.#conductorId(userId, state); if (typeof id !== "string") { @@ -344,8 +353,13 @@ export class SlackFrontend implements Frontend { return; } } + // Signed in again, or attached elsewhere, while this waited: the message isn't sent under the new sign-in. + if (state.epoch !== epoch || !state.auth || !state.attachedSessionId) { + await this.#post(channel, "⚠️ Not sent: your sign-in changed meanwhile. Send it again."); + return; + } const resp = await this.#manager.handle( - { type: "session.send", id: randomUUID(), sessionId: state.attachedSessionId!, text }, + { type: "session.send", id: randomUUID(), sessionId: state.attachedSessionId, text }, state.auth!, this.#client(userId, state), ); @@ -385,14 +399,16 @@ export class SlackFrontend implements Frontend { if (msg.contentAppend && state.streams.has(msg.messageId)) state.streams.set(msg.messageId, (state.streams.get(msg.messageId) ?? "") + msg.contentAppend); // A card's call answered elsewhere (the web UI, the CLI) or cancelled: its buttons go. const phase = msg.toolStateUpdate?.phase; - const key = this.#cardOfMessage.get(msg.messageId); - if (key && phase && phase !== "waiting_confirmation") { - this.#cardOfMessage.delete(msg.messageId); - const card = this.#approvals.get(key); - if (card) { - this.#approvals.delete(key); - if (card.ts) this.#queue(userId, () => this.#update(card.channel, card.ts!, "Answered elsewhere.")); + if (phase && phase !== "waiting_confirmation") { + this.#answered.add(msg.messageId); + if (this.#answered.size > 1000) this.#answered.delete(this.#answered.values().next().value!); + for (const key of this.#cardOfMessage.get(msg.messageId) ?? []) { + const card = this.#approvals.get(key); + if (!card) continue; + this.#dropCard(key); + if (card.ts) this.#queue(card.userId, () => this.#update(card.channel, card.ts!, "Answered elsewhere.")); } + this.#cardOfMessage.delete(msg.messageId); } return; } @@ -429,15 +445,19 @@ export class SlackFrontend implements Frontend { return; } this.#prune(); + // Answered meanwhile (in the web UI, while this waited its turn to post): nothing to show. + if (messageId && this.#answered.has(messageId)) return; const key = `${userId}:${st.approvalId}`; if (this.#approvals.has(key)) return; - const pending: PendingApproval = { approvalId: st.approvalId, sessionId, userId, channel, createdAt: Date.now() }; + const pending: PendingApproval = { approvalId: st.approvalId, sessionId, userId, channel, createdAt: Date.now(), ...(messageId ? { messageId } : {}) }; this.#approvals.set(key, pending); - if (messageId) this.#cardOfMessage.set(messageId, key); - const full = esc(String(st.description ?? tool.name)); - // Never cut silently: a tail it doesn't show could be what runs. - const shown = full.length > 2_000 ? `${full.slice(0, 2_000)}…\n_(cut short: see the whole call in the web UI or with \`codeoid approve\`)_` : full; - const lines = [`*Approval needed* — \`${esc(tool.name)}\``, shown]; + if (messageId) { + const keys = this.#cardOfMessage.get(messageId) ?? new Set(); + keys.add(key); + this.#cardOfMessage.set(messageId, keys); + } + // What runs, from its input: the description only names the tool and its arguments. + const lines = [`*Approval needed* — \`${esc(tool.name)}\``, callText(tool.name, st.input)]; const tw = st.targetWorkstream; if (tw?.workstream) lines.push(`⌂ workstream *${esc(tw.workstream)}*`); if (tw?.warning) lines.push(`⚠ ${esc(tw.warning)}`); @@ -454,7 +474,8 @@ export class SlackFrontend implements Frontend { unfurl_links: false, unfurl_media: false, blocks: [ - { type: "section", text: { type: "mrkdwn", text: text.slice(0, CHUNK) } }, + // Split across sections (each holds 3000), never cut. + ...sections(text), { type: "actions", block_id: "codeoid_approval", @@ -485,11 +506,15 @@ export class SlackFrontend implements Frontend { const key = `${userId}:${action.value ?? ""}`; const pending = this.#approvals.get(key); if (!pending) { + // A press repeated (a double click, Slack resending it): the first already said what happened. + if (this.#justAnswered.has(key)) return; const someoneElses = [...this.#approvals.keys()].some((k) => k.endsWith(`:${action.value}`)); await say(someoneElses ? "This approval isn't yours." : "This approval is no longer waiting."); return; } - this.#approvals.delete(key); + this.#dropCard(key); + this.#justAnswered.add(key); + if (this.#justAnswered.size > 500) this.#justAnswered.delete(this.#justAnswered.values().next().value!); if (Date.now() - pending.createdAt > this.#approvalTtlMs) { await say("This approval expired."); return; @@ -511,14 +536,24 @@ export class SlackFrontend implements Frontend { #prune(): void { const now = Date.now(); - for (const [k, a] of this.#approvals) if (now - a.createdAt > this.#approvalTtlMs) this.#approvals.delete(k); + for (const [k, a] of this.#approvals) if (now - a.createdAt > this.#approvalTtlMs) this.#dropCard(k); while (this.#approvals.size >= MAX_PENDING_APPROVALS) { const oldest = this.#approvals.keys().next().value; if (!oldest) break; - this.#approvals.delete(oldest); + this.#dropCard(oldest); } } + /** Forget one card, and its place in #cardOfMessage. */ + #dropCard(key: string): void { + const card = this.#approvals.get(key); + this.#approvals.delete(key); + if (!card?.messageId) return; + const keys = this.#cardOfMessage.get(card.messageId); + keys?.delete(key); + if (keys && keys.size === 0) this.#cardOfMessage.delete(card.messageId); + } + // ── "Needs you": another of your sessions waits on you ──────────────── #needsYou(sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }): void { @@ -575,7 +610,7 @@ export class SlackFrontend implements Frontend { state.attachedSessionId = null; state.attachedName = null; state.streams.clear(); - for (const [k, a] of this.#approvals) if (a.userId === userId) this.#approvals.delete(k); + for (const [k, a] of this.#approvals) if (a.userId === userId) this.#dropCard(k); } #client(userId: string, state: UserState): AttachedClient { @@ -616,5 +651,48 @@ export function esc(text: string): string { * JWT, or a Slack token. It is never forwarded to a session. */ export function looksLikeSecret(text: string): boolean { - return /\bzid_sk_[A-Za-z0-9_-]{8,}/.test(text) || /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/.test(text) || /\bxox[abposr]-[A-Za-z0-9-]{8,}|\bxapp-[A-Za-z0-9-]{8,}/.test(text); + return ( + new RegExp(`(?:${KNOWN_KEY_FORMATS})`).test(text) || + /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/.test(text) || + /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text) + ); +} + +/** Most characters of a call a card shows; past it the card says it was cut. */ +export const CALL_SHOWN = 2_400; + +/** + * What a tool call would do, as a card shows it: the command a shell runs, + * the file a write or edit touches with what it writes, a plan; else its + * input whole. Escaped for Slack, and cut only with a note saying so. + */ +export function callText(_toolName: string, input: unknown): string { + const i = input && typeof input === "object" ? (input as Record) : {}; + const str = (v: unknown) => (typeof v === "string" ? v : undefined); + let head = ""; + let body: string; + const command = str(i.command) ?? str(i.cmd); + const path = str(i.file_path) ?? str(i.path) ?? str(i.notebook_path); + if (command !== undefined) body = command; + else if (str(i.plan) !== undefined) body = str(i.plan)!; + else if (path !== undefined && (str(i.content) !== undefined || str(i.new_string) !== undefined)) { + head = path; + body = str(i.content) ?? `${str(i.old_string) ?? ""}\n→\n${str(i.new_string) ?? ""}`; + } else body = JSON.stringify(input ?? {}, null, 2); + const cut = body.length > CALL_SHOWN; + const shown = esc(cut ? body.slice(0, CALL_SHOWN) : body).replace(/```/g, "ˋˋˋ"); + return `${head ? `\`${esc(head)}\`\n` : ""}\`\`\`${shown}\`\`\`${cut ? `\n_(cut short at ${CALL_SHOWN} of ${body.length} characters: see the whole call in the web UI or with \`codeoid approve\`)_` : ""}`; +} + +/** Text as mrkdwn section blocks of at most CHUNK characters each, split at line ends where it can. */ +function sections(text: string): Array> { + const out: Array> = []; + let rest = text; + while (rest.length > 0) { + let at = rest.length <= CHUNK ? rest.length : rest.lastIndexOf("\n", CHUNK); + if (at <= 0) at = Math.min(CHUNK, rest.length); + out.push({ type: "section", text: { type: "mrkdwn", text: rest.slice(0, at) } }); + rest = rest.slice(at).replace(/^\n/, ""); + } + return out; } diff --git a/src/frontends/slack/socket.ts b/src/frontends/slack/socket.ts index 67c46d2e..8fb4e299 100644 --- a/src/frontends/slack/socket.ts +++ b/src/frontends/slack/socket.ts @@ -81,8 +81,12 @@ export async function slackApi>( /** How long a slash command's handler has to answer inside the ack; a slower answer goes to its response_url. */ const SLASH_ACK_MS = 2_500; -/** Event ids remembered to drop Slack's retries of what was already handled. */ +/** Envelope and event ids remembered to drop Slack's retries of what was already handled. */ const SEEN_EVENTS = 500; +/** Longest one envelope's handling holds its user's queue; past it the queue moves on (and says so). */ +const HANDLER_CEILING_MS = 60_000; +/** Slack's answers that mean the app token itself is no good: retrying won't help. */ +const FATAL = new Set(["invalid_auth", "not_authed", "account_inactive", "token_revoked", "not_allowed_token_type"]); /** * One Socket Mode connection, kept open: reconnects when Slack sends a @@ -99,10 +103,13 @@ export class SlackSocket { #transport: SlackTransport; #onEnvelope: (env: SlackEnvelope) => Promise | undefined>; #socket: SocketLike | null = null; + /** Every socket not yet closed, the one being replaced included: stop() closes them all. */ + #live = new Set(); #stopped = false; #backoffMs = 1_000; #timer: ReturnType | null = null; - #inbound: Promise = Promise.resolve(); + /** One queue per Slack user: one user's slow handling never holds up another's. */ + #inbound = new Map>(); #seen = new Set(); constructor( @@ -115,19 +122,31 @@ export class SlackSocket { this.#transport = transport; } - /** Ask Slack for a connection and open it; throws if Slack refuses the app token. Reconnects on its own after. */ + /** + * Ask Slack for a connection and open it. Throws when Slack refuses the app + * token; when Slack can't be reached, keeps trying in the background. + */ async start(): Promise { this.#stopped = false; - await this.#open(); + try { + await this.#open(); + } catch (err) { + if (err instanceof SlackApiError && FATAL.has(err.code)) throw err; + console.error(`[codeoid:slack] can't reach Slack yet, retrying: ${err instanceof Error ? err.message : String(err)}`); + this.#reconnect(); + } } stop(): void { this.#stopped = true; if (this.#timer) clearTimeout(this.#timer); this.#timer = null; - const s = this.#socket; this.#socket = null; - s?.close(); + for (const s of this.#live) { + s.onclose = null; + s.close(); + } + this.#live.clear(); } async #open(): Promise { @@ -136,9 +155,11 @@ export class SlackSocket { const previous = this.#socket; const socket = this.#transport.connect(url); this.#socket = socket; + this.#live.add(socket); socket.onmessage = (ev) => this.#receive(socket, ev.data); socket.onerror = () => {}; socket.onclose = () => { + this.#live.delete(socket); if (this.#socket === socket) { this.#socket = null; this.#reconnect(); @@ -149,6 +170,7 @@ export class SlackSocket { if (previous && previous !== socket) { previous.onclose = null; previous.close(); + this.#live.delete(previous); } }; } @@ -160,6 +182,11 @@ export class SlackSocket { this.#timer = setTimeout(() => { this.#timer = null; this.#open().catch((err) => { + if (err instanceof SlackApiError && FATAL.has(err.code)) { + console.error(`[codeoid:slack] Slack refused the app token (${err.code}): stopped. Set a valid CODEOID_SLACK_APP_TOKEN and restart.`); + this.#stopped = true; + return; + } console.error(`[codeoid:slack] reconnecting failed: ${err instanceof Error ? err.message : String(err)}`); this.#reconnect(); }); @@ -179,8 +206,8 @@ export class SlackSocket { return; } if (msg.type === "disconnect") { - // Slack is about to close this one: open the next, which closes this when it is up. - this.#open().catch(() => this.#reconnect()); + // Slack is about to close this one: open the next, which closes this when it is up (once per socket). + if (this.#socket === socket) this.#open().catch(() => this.#reconnect()); return; } if (!msg.envelope_id || !msg.payload || typeof msg.payload !== "object") return; @@ -197,20 +224,43 @@ export class SlackSocket { // Closed meanwhile: Slack resends it on the next connection (and a resent event is dropped below). } }; + // A retry of what was handled (same envelope, or the same event in a new one): acknowledged, not redone. + const eventId = typeof env.payload.event_id === "string" ? env.payload.event_id : undefined; + if (this.#remember(`env:${env.envelope_id}`) || (eventId && this.#remember(`ev:${eventId}`))) { + ack(); + return; + } if (env.type === "slash_commands") { void this.#slash(env, ack); return; } ack(); - const eventId = typeof env.payload.event_id === "string" ? env.payload.event_id : undefined; - if (eventId) { - if (this.#seen.has(eventId)) return; - this.#seen.add(eventId); - if (this.#seen.size > SEEN_EVENTS) this.#seen.delete(this.#seen.values().next().value!); - } - this.#inbound = this.#inbound.then(async () => { - await this.#handle(env); + const user = userOf(env.payload); + const prior = this.#inbound.get(user) ?? Promise.resolve(); + const next = prior.then(() => this.#bounded(env)); + this.#inbound.set(user, next); + void next.then(() => { + if (this.#inbound.get(user) === next) this.#inbound.delete(user); + }); + } + + /** Whether `id` was seen before (remembering it if not). */ + #remember(id: string): boolean { + if (this.#seen.has(id)) return true; + this.#seen.add(id); + if (this.#seen.size > SEEN_EVENTS) this.#seen.delete(this.#seen.values().next().value!); + return false; + } + + /** Handle one envelope, holding its user's queue at most HANDLER_CEILING_MS. */ + async #bounded(env: SlackEnvelope): Promise { + let timer: ReturnType | undefined; + const ceiling = new Promise<"late">((r) => { + timer = setTimeout(() => r("late"), HANDLER_CEILING_MS); }); + const outcome = await Promise.race([this.#handle(env).then(() => "done" as const), ceiling]); + clearTimeout(timer); + if (outcome === "late") console.error(`[codeoid:slack] handling a ${env.type} took over ${HANDLER_CEILING_MS / 1000}s; moving on`); } async #handle(env: SlackEnvelope): Promise | undefined> { @@ -224,7 +274,6 @@ export class SlackSocket { /** A slash command: its answer in the ack if it comes within SLASH_ACK_MS, else posted to its response_url. */ async #slash(env: SlackEnvelope, ack: (payload?: Record) => void): Promise { - let late = false; const timer = new Promise<"late">((r) => setTimeout(() => r("late"), SLASH_ACK_MS)); const work = this.#handle(env); const first = await Promise.race([work, timer]); @@ -232,14 +281,21 @@ export class SlackSocket { ack(first); return; } - late = true; ack(); const answer = await work; const url = typeof env.payload.response_url === "string" ? env.payload.response_url : undefined; - if (late && answer && url?.startsWith("https://hooks.slack.com/")) { + if (answer && url?.startsWith("https://hooks.slack.com/")) { await this.#transport .fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(answer), signal: AbortSignal.timeout(15_000) }) .catch(() => {}); } } } + +/** The Slack user an envelope is from, for its queue ("" when it names none). */ +function userOf(p: Record): string { + const event = p.event as { user?: unknown } | undefined; + const user = p.user as { id?: unknown } | undefined; + const id = event?.user ?? user?.id ?? p.user_id; + return typeof id === "string" ? id : ""; +} diff --git a/src/tests/cli-approve.test.ts b/src/tests/cli-approve.test.ts index 75c5a00c..eb1e81a2 100644 --- a/src/tests/cli-approve.test.ts +++ b/src/tests/cli-approve.test.ts @@ -205,7 +205,7 @@ describe("session.approvals", () => { { type: "session.approvals.result" } >; expect(res.approvals).toEqual([ - { approvalId: "approval-1", toolName: "Bash", input: { command: "rm -rf build" }, risk: { level: "medium", reasons: [expect.stringContaining("rm deletes")] } }, + { approvalId: "approval-1", messageId: expect.any(String), toolName: "Bash", input: { command: "rm -rf build" }, risk: { level: "medium", reasons: [expect.stringContaining("rm deletes")] } }, ]); const session = manager._sessionForTest(id)!; diff --git a/src/tests/slack-frontend.test.ts b/src/tests/slack-frontend.test.ts index 49e8b6e7..f9d3b1f1 100644 --- a/src/tests/slack-frontend.test.ts +++ b/src/tests/slack-frontend.test.ts @@ -15,7 +15,7 @@ import { SessionManager } from "../daemon/session-manager.js"; import { Store } from "../daemon/store.js"; import { TranscriptStore } from "../daemon/transcript.js"; import type { TokenVerifier } from "../daemon/verifier.js"; -import { SlackFrontend, esc, looksLikeSecret } from "../frontends/slack/index.js"; +import { CALL_SHOWN, SlackFrontend, callText, esc, looksLikeSecret } from "../frontends/slack/index.js"; import type { SlackTransport, SocketLike } from "../frontends/slack/socket.js"; import type { FrontendContext } from "../frontends/types.js"; import { ALL_SCOPES } from "../protocol/scopes.js"; @@ -183,9 +183,12 @@ describe("approvals", () => { await until(() => (providers.at(-1)?.canUseToolResults.length ?? 0) > 0); expect(providers.at(-1)!.canUseToolResults[0]!.behavior).toBe("allow"); await until(() => calls.some((c) => c.method === "chat.update" && String(c.body.text).includes("Approved"))); - // Pressed once: a second press finds nothing waiting. + // Pressed once: a second press decides nothing more, and says nothing more. + const updates = calls.filter((c) => c.method === "chat.update").length; await press(OWNER, "approve", "ap-1"); - await until(() => calls.some((c) => c.method === "chat.update" && String(c.body.text).includes("no longer waiting"))); + await Bun.sleep(50); + expect(calls.filter((c) => c.method === "chat.update").length).toBe(updates); + expect(providers.at(-1)!.canUseToolResults.length).toBe(1); }); it("another session of yours waiting on you is told in your DM", async () => { @@ -270,3 +273,37 @@ describe("one workspace", () => { expect(await frontend.handleEnvelope({ envelope_id: "e", type: "slash_commands", payload: { team_id: "T1", user_id: OWNER, text: "auth tok-owner" } })).toMatchObject({ text: expect.stringContaining("Signed in") }); }); }); + +describe("audit round 2", () => { + it("a card shows what runs: the command, the file and what it writes, cut only with a note", async () => { + script = [[{ type: "tool_start", toolId: "t1", sdkToolUseId: "s1", name: "Bash", input: { command: "rm -rf ~/important", description: "tidy" }, approvalId: "ap-c1" } as ProviderEvent, done]]; + await slash(OWNER, "auth tok-owner"); + await dm(OWNER, "tidy up"); + await until(() => calls.some((c) => Array.isArray(c.body.blocks))); + expect(JSON.stringify(calls.find((c) => Array.isArray(c.body.blocks))!.body.blocks)).toContain("rm -rf ~/important"); + expect(callText("Write", { file_path: "/w/a.ts", content: "export const x = 1;" })).toContain("/w/a.ts"); + expect(callText("Write", { file_path: "/w/a.ts", content: "export const x = 1;" })).toContain("export const x = 1;"); + const long = callText("Bash", { command: `${"a".repeat(CALL_SHOWN)}; rm -rf ~` }); + expect(long).toContain("cut short"); + expect(callText("Bash", { command: "echo " })).toContain("<!channel>"); + }); + + it("common provider keys and private keys are caught too", () => { + for (const k of ["sk-ant-api03-abcdefghij", "ghp_abcdefghij1234", "github_pat_abcdefghij123", "AKIAABCDEFGHIJKLMNOP", "AIzaSyABCDEFGHIJKLMNOPQRSTU", "MY_KEY_zid_sk_abcdefghij", "-----BEGIN OPENSSH PRIVATE KEY-----"]) { + expect([k, looksLikeSecret(k)]).toEqual([k, true]); + } + }); + + it("a press repeated after the card was answered says nothing more", async () => { + script = [[{ type: "tool_start", toolId: "t2", sdkToolUseId: "s2", name: "Write", input: { file_path: join(tmp, "r.txt"), content: "x" }, approvalId: "ap-r1" } as ProviderEvent, done]]; + await slash(OWNER, "auth tok-owner"); + await dm(OWNER, "write"); + await until(() => calls.some((c) => Array.isArray(c.body.blocks))); + await press(OWNER, "approve", "ap-r1"); + await until(() => calls.some((c) => c.method === "chat.update" && String(c.body.text).includes("Approved"))); + const updates = calls.filter((c) => c.method === "chat.update").length; + await press(OWNER, "approve", "ap-r1"); + await Bun.sleep(50); + expect(calls.filter((c) => c.method === "chat.update").length).toBe(updates); + }); +}); diff --git a/src/tests/slack-socket.test.ts b/src/tests/slack-socket.test.ts index 8a89a074..886bcc1b 100644 --- a/src/tests/slack-socket.test.ts +++ b/src/tests/slack-socket.test.ts @@ -110,4 +110,81 @@ describe("SlackSocket", () => { expect(posted).toEqual([JSON.stringify({ text: "late answer" })]); socket.stop(); }); + + it("one user's slow handling never holds up another's", async () => { + const slack = fakeSlack(); + const done: string[] = []; + const socket = new SlackSocket("xapp", async (env) => { + const user = String((env.payload.event as { user?: string }).user); + if (user === "SLOW") await new Promise(() => {}); + done.push(user); + return undefined; + }, slack.transport); + await socket.start(); + const s = slack.sockets[0]!; + s.onmessage?.({ data: JSON.stringify({ envelope_id: "e1", type: "events_api", payload: { event_id: "A", event: { user: "SLOW" } } }) }); + s.onmessage?.({ data: JSON.stringify({ envelope_id: "e2", type: "events_api", payload: { event_id: "B", event: { user: "FAST" } } }) }); + await Bun.sleep(20); + expect(done).toEqual(["FAST"]); + socket.stop(); + }); + + it("an envelope Slack resends (a press, a command) is acknowledged and not handled again", async () => { + const slack = fakeSlack(); + let handled = 0; + const socket = new SlackSocket("xapp", async () => { + handled++; + return undefined; + }, slack.transport); + await socket.start(); + const s = slack.sockets[0]!; + const press = JSON.stringify({ envelope_id: "same", type: "interactive", payload: { type: "block_actions", user: { id: "U" } } }); + s.onmessage?.({ data: press }); + s.onmessage?.({ data: press }); + await Bun.sleep(20); + expect(handled).toBe(1); + expect(s.sent.length).toBe(2); + socket.stop(); + }); + + it("stop closes every socket, the one being replaced included", async () => { + const slack = fakeSlack(); + const closed: number[] = []; + const connect = slack.transport.connect; + slack.transport.connect = (url) => { + const sock = connect(url); + const n = slack.sockets.length; + sock.close = () => { + closed.push(n); + }; + return sock; + }; + const socket = new SlackSocket("xapp", async () => undefined, slack.transport); + await socket.start(); + slack.sockets[0]!.onmessage?.({ data: JSON.stringify({ type: "disconnect" }) }); + await Bun.sleep(20); + expect(slack.sockets.length).toBe(2); + socket.stop(); + expect(closed.sort()).toEqual([1, 2]); + }); + + it("a start that can't reach Slack keeps trying; a refused token stops it", async () => { + let tries = 0; + const down: SlackTransport = { + fetch: async () => { + tries++; + throw new Error("network down"); + }, + connect: () => { + throw new Error("never"); + }, + }; + const socket = new SlackSocket("xapp", async () => undefined, down); + await socket.start(); + for (let i = 0; i < 200 && tries < 2; i++) await Bun.sleep(10); + expect(tries).toBe(2); + socket.stop(); + const refused: SlackTransport = { fetch: async () => Response.json({ ok: false, error: "invalid_auth" }), connect: () => { throw new Error("never"); } }; + await expect(new SlackSocket("xapp", async () => undefined, refused).start()).rejects.toThrow("invalid_auth"); + }); });