From 94008708f43a96539f5fcb36f8123c51c6b8ed79 Mon Sep 17 00:00:00 2001 From: Bee <68532117+abeatrix@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:37:36 -0700 Subject: [PATCH] fix: restrict package publishing and improve Slack delivery (#65) - Set `publishConfig` to "restricted" and update the release script to enforce restricted access. - Refactor Slack token handling to introduce a `withSlackTeamBotToken` helper. - Improve delivery robustness by detecting `invalid_thread_ts` errors and automatically clearing stale Slack thread bindings. - Add unit tests for Slack token routing and error detection logic. --- sdk/apps/cli/package.json | 5 +- .../cli/src/connectors/adapters/slack.test.ts | 37 +++ sdk/apps/cli/src/connectors/adapters/slack.ts | 292 ++++++++++++------ 3 files changed, 234 insertions(+), 100 deletions(-) diff --git a/sdk/apps/cli/package.json b/sdk/apps/cli/package.json index 62a32d9ee5..90f9f85a38 100644 --- a/sdk/apps/cli/package.json +++ b/sdk/apps/cli/package.json @@ -4,6 +4,9 @@ "version": "0.0.0", "description": "[EXPERIMENTAL] A lightweight Cline CLI built with the Cline SDKs", "type": "module", + "publishConfig": { + "access": "restricted" + }, "bin": { "clite": "dist/index.js" }, @@ -27,7 +30,7 @@ "test:e2e:interactive": "vitest run --config vitest.interactive.e2e.config.ts", "test:watch": "vitest --config vitest.config.ts", "test:e2e:cli:tui": "cd src/tests && tui-test", - "release": "bun run build && bun publish --access public", + "release": "bun run build && bun publish --access restricted", "link": "bun unlink && bun link" }, "dependencies": { diff --git a/sdk/apps/cli/src/connectors/adapters/slack.test.ts b/sdk/apps/cli/src/connectors/adapters/slack.test.ts index 846864d7ff..9b996fc566 100644 --- a/sdk/apps/cli/src/connectors/adapters/slack.test.ts +++ b/sdk/apps/cli/src/connectors/adapters/slack.test.ts @@ -121,4 +121,41 @@ describe("slack binding lookup", () => { label: "alice", }); }); + + it("routes Slack posts through the installation bot token for a team", async () => { + const calls: string[] = []; + const result = await __test__.withSlackTeamBotToken({ + slack: { + getInstallation: async (teamId: string) => { + calls.push(`get:${teamId}`); + return { botToken: "xoxb-team-token" }; + }, + withBotToken: (token: string, work: () => T): T => { + calls.push(`token:${token}`); + return work(); + }, + }, + teamId: "T123", + work: async () => { + calls.push("work"); + return "ok"; + }, + }); + + expect(result).toBe("ok"); + expect(calls).toEqual(["get:T123", "token:xoxb-team-token", "work"]); + }); + + it("detects Slack invalid_thread_ts errors", () => { + expect( + __test__.isSlackInvalidThreadTsError( + new Error("An API error occurred: invalid_thread_ts"), + ), + ).toBe(true); + expect( + __test__.isSlackInvalidThreadTsError( + new Error("An API error occurred: channel_not_found"), + ), + ).toBe(false); + }); }); diff --git a/sdk/apps/cli/src/connectors/adapters/slack.ts b/sdk/apps/cli/src/connectors/adapters/slack.ts index 383e465853..5b6307b470 100644 --- a/sdk/apps/cli/src/connectors/adapters/slack.ts +++ b/sdk/apps/cli/src/connectors/adapters/slack.ts @@ -52,6 +52,7 @@ import { loadThreadState, persistMergedThreadState, readBindings, + writeBindings, } from "../thread-bindings"; import type { ConnectCommandDefinition, @@ -170,11 +171,23 @@ function extractSlackTeamId(raw: unknown): string | undefined { } async function withSlackBindingBotToken(input: { - slack: SlackAdapter; + slack: Pick; binding: ConnectorThreadBinding; work: () => Promise; }): Promise { - const teamId = input.binding.state?.teamId?.trim(); + return withSlackTeamBotToken({ + slack: input.slack, + teamId: input.binding.state?.teamId, + work: input.work, + }); +} + +async function withSlackTeamBotToken(input: { + slack: Pick; + teamId?: string; + work: () => Promise; +}): Promise { + const teamId = input.teamId?.trim(); if (!teamId) { return input.work(); } @@ -185,6 +198,33 @@ async function withSlackBindingBotToken(input: { return input.slack.withBotToken(installation.botToken, input.work); } +function isSlackInvalidThreadTsError(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : ""; + return /\binvalid_thread_ts\b/i.test(message); +} + +function clearSlackBinding( + bindingsPath: string, + bindingKey: string | undefined, +): boolean { + const key = bindingKey?.trim(); + if (!key) { + return false; + } + const bindings = readBindings(bindingsPath); + if (!bindings[key]) { + return false; + } + delete bindings[key]; + writeBindings(bindingsPath, bindings); + return true; +} + async function persistSlackThreadContext(input: { thread: Thread; bindingsPath: string; @@ -266,6 +306,7 @@ async function deliverScheduledResult(input: { ? { key: threadId, binding: bindings[threadId] } : undefined; const binding = match?.binding; + const deliveryThreadId = match?.key || threadId || bindingKey; if (!binding?.serializedThread) { return; } @@ -282,11 +323,29 @@ async function deliverScheduledResult(input: { } else { body = `Schedule "${schedule?.name ?? input.scheduleId}" ${input.status}.${input.errorMessage ? `\n\n${input.errorMessage}` : ""}`; } - await withSlackBindingBotToken({ - slack: input.slack, - binding, - work: () => thread.post(body).then(() => undefined), - }); + try { + await withSlackBindingBotToken({ + slack: input.slack, + binding, + work: () => thread.post(body).then(() => undefined), + }); + } catch (error) { + if ( + isSlackInvalidThreadTsError(error) && + clearSlackBinding(input.bindingsPath, deliveryThreadId) + ) { + input.logger.core.warn?.( + "Cleared stale Slack binding after invalid_thread_ts", + { + transport: "slack", + threadId: deliveryThreadId, + scheduleId: input.scheduleId, + executionId: input.executionId, + }, + ); + } + throw error; + } } class SlackConnector extends ConnectorBase< @@ -627,101 +686,118 @@ class SlackConnector extends ConnectorBase< thread: Thread, text: string, ) => { - const queueKey = - (await loadThreadState(thread, bindingsPath, startRequest)) - .participantKey || thread.id; + const currentState = await loadThreadState( + thread, + bindingsPath, + startRequest, + ); + const queueKey = currentState.participantKey || thread.id; const runTurn = async () => { try { - await handleConnectorUserTurn({ - thread, - text, - client, - pendingApprovals, - baseStartRequest: startRequest, - explicitSystemPrompt: - options.systemPrompt?.trim() || getConnectorSystemPrompt("slack"), - clientId, - logger: loggerAdapter, - transport: "slack", - botUserName: options.userName, - requestStop, - bindingsPath, - hookCommand: options.hookCommand, - systemRules: SLACK_SYSTEM_RULES, - errorLabel: "Slack", - firstContactMessage: SLACK_FIRST_CONTACT_MESSAGE, - userInstructionWatcher, - chatCommandHost, - activeTurns, - turnKey: queueKey, - getSessionMetadata: (currentThread, _clientId, currentState) => ({ - userName: options.userName, - slackThreadId: currentThread.id, - slackChannelId: currentThread.channelId, - ...(currentState.participantKey - ? { slackParticipantKey: currentState.participantKey } - : {}), - ...(currentState.participantLabel - ? { slackParticipantLabel: currentState.participantLabel } - : {}), - }), - reusedLogMessage: "Slack thread reusing RPC session", - startedLogMessage: "Slack thread started RPC session", - onMessageReceived: async (details) => { - await dispatchConnectorHook( - options.hookCommand, - { - adapter: "slack", - botUserName: options.userName, - event: "message.received", - payload: details, - ts: new Date().toISOString(), + await withSlackTeamBotToken({ + slack, + teamId: currentState.teamId, + work: async () => + handleConnectorUserTurn({ + thread, + text, + client, + pendingApprovals, + baseStartRequest: startRequest, + explicitSystemPrompt: + options.systemPrompt?.trim() || + getConnectorSystemPrompt("slack"), + clientId, + logger: loggerAdapter, + transport: "slack", + botUserName: options.userName, + requestStop, + bindingsPath, + hookCommand: options.hookCommand, + systemRules: SLACK_SYSTEM_RULES, + errorLabel: "Slack", + firstContactMessage: SLACK_FIRST_CONTACT_MESSAGE, + userInstructionWatcher, + chatCommandHost, + activeTurns, + turnKey: queueKey, + getSessionMetadata: ( + currentThread, + _clientId, + currentState, + ) => ({ + userName: options.userName, + slackThreadId: currentThread.id, + slackChannelId: currentThread.channelId, + ...(currentState.participantKey + ? { slackParticipantKey: currentState.participantKey } + : {}), + ...(currentState.participantLabel + ? { slackParticipantLabel: currentState.participantLabel } + : {}), + }), + reusedLogMessage: "Slack thread reusing RPC session", + startedLogMessage: "Slack thread started RPC session", + onMessageReceived: async (details) => { + await dispatchConnectorHook( + options.hookCommand, + { + adapter: "slack", + botUserName: options.userName, + event: "message.received", + payload: details, + ts: new Date().toISOString(), + }, + loggerAdapter, + ); }, - loggerAdapter, - ); - }, - onReplyCompleted: async (result) => { - await dispatchConnectorHook( - options.hookCommand, - { - adapter: "slack", - botUserName: options.userName, - event: "message.completed", - payload: { - threadId: result.threadId, - sessionId: result.sessionId, - finishReason: result.finishReason, - iterations: result.iterations, - outputPreview: truncateText(result.text), - outputLength: result.text.length, - }, - ts: new Date().toISOString(), + onReplyCompleted: async (result) => { + await dispatchConnectorHook( + options.hookCommand, + { + adapter: "slack", + botUserName: options.userName, + event: "message.completed", + payload: { + threadId: result.threadId, + sessionId: result.sessionId, + finishReason: result.finishReason, + iterations: result.iterations, + outputPreview: truncateText(result.text), + outputLength: result.text.length, + }, + ts: new Date().toISOString(), + }, + loggerAdapter, + ); }, - loggerAdapter, - ); - }, - onReplyFailed: async (details) => { - await dispatchConnectorHook( - options.hookCommand, - { - adapter: "slack", - botUserName: options.userName, - event: "message.failed", - payload: { - threadId: details.threadId, - sessionId: details.sessionId, - error: details.error.message, - }, - ts: new Date().toISOString(), + onReplyFailed: async (details) => { + await dispatchConnectorHook( + options.hookCommand, + { + adapter: "slack", + botUserName: options.userName, + event: "message.failed", + payload: { + threadId: details.threadId, + sessionId: details.sessionId, + error: details.error.message, + }, + ts: new Date().toISOString(), + }, + loggerAdapter, + ); }, - loggerAdapter, - ); - }, + }), }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - await thread.post(`Slack bridge error: ${message}`); + await withSlackTeamBotToken({ + slack, + teamId: currentState.teamId, + work: () => thread.post(`Slack bridge error: ${message}`), + }); } }; if (activeTurns.has(queueKey)) { @@ -814,12 +890,28 @@ class SlackConnector extends ConnectorBase< logger: loggerAdapter, bindingsPath, transport: "slack", - postToThread: async ({ thread, binding, body }) => { - await withSlackBindingBotToken({ - slack, - binding, - work: () => thread.post(body).then(() => undefined), - }); + postToThread: async ({ thread, binding, body, threadId }) => { + try { + await withSlackBindingBotToken({ + slack, + binding, + work: () => thread.post(body).then(() => undefined), + }); + } catch (error) { + if ( + isSlackInvalidThreadTsError(error) && + clearSlackBinding(bindingsPath, threadId) + ) { + loggerAdapter.core.warn?.( + "Cleared stale Slack binding after invalid_thread_ts", + { + transport: "slack", + threadId, + }, + ); + } + throw error; + } }, }); @@ -945,6 +1037,8 @@ export const slackConnector: ConnectCommandDefinition = new SlackConnector(); export const __test__ = { buildSlackParticipantKey, resolveSlackParticipant, + withSlackTeamBotToken, + isSlackInvalidThreadTsError, findBindingForThread: ( bindings: ConnectorBindingStore, thread: Pick, "id" | "channelId" | "isDM"> & {