From 62148d98053747ef1c4951152442ed6bd562201a Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Wed, 23 Sep 2026 12:45:28 +0800 Subject: [PATCH] feat: link Linear accounts from Slack --- README.md | 21 +++++--- src/codex.mjs | 4 +- src/controller.mjs | 16 +++--- src/linear-link.mjs | 103 +++++++++++++++++++++++++++++++++++++ src/oauth.mjs | 12 ++--- src/proxy.mjs | 3 +- src/slack-channel.mjs | 12 ++++- src/slack-events.mjs | 7 +++ src/userlogins.mjs | 25 ++++++--- test/codex.test.mjs | 2 +- test/linear-link.test.mjs | 99 +++++++++++++++++++++++++++++++++++ test/oauth.test.mjs | 3 +- test/slack-events.test.mjs | 9 +++- test/slack-link.test.mjs | 52 +++++++++++++++++++ 14 files changed, 335 insertions(+), 33 deletions(-) create mode 100644 src/linear-link.mjs create mode 100644 test/linear-link.test.mjs create mode 100644 test/slack-link.test.mjs diff --git a/README.md b/README.md index f38e8cf..6b6683f 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ asking, so only what they can see — and comment and file things in Linear and - **GitHub and Slack never mix.** See [below](#github-and-slack-never-mix). - **PRs only for people who may ask.** Codex commits in its box; the controller checks the change and opens the PR. Everyone else gets the change as a diff in the reply. -- **Slack needs no public URL.** The controller dials out to Slack (Socket Mode). +- **Slack events use Socket Mode.** The controller dials out to Slack; browser account linking + uses the controller's existing public HTTPS URL. - **The team's tools go through the controller too.** Linear, Notion and Google Workspace reach Codex as MCP servers on the controller, behind the same job token. The controller checks every call against the tools you allow and swaps in the right login — in Slack the asker's own. See @@ -122,6 +123,11 @@ same state: `@boxliteai /model …`, `/deploy` (it reports back in that Slack th `/resume`, so one `/pause` stops PR writing on both. `/add`, `/remove` and `/list` stay on GitHub: in Slack every member may ask for PRs. +Every Slack member can send `@boxliteai /link linear` (or `/link linear` in a DM). Open the +private, ten-minute link, continue to Linear, and approve your account. The browser confirms +when connected; your next DM request uses your account. A new command replaces an unfinished +link, and a controller restart expires unfinished links. Linked accounts survive restarts. + `/model` takes a model only if ChatGPT's Codex backend offers it (and that effort) to the bot's pinned Codex, since a bad one would fail every turn. @@ -239,8 +245,8 @@ answered Dorian, changed: Linear save_issue · Notion notion-create-pages | | Linear | Notion | Google Workspace | |---|---|---|---| | What it sees | what that member sees | the pages shared with it | files and calendars shared with it | -| A Slack person links their own | `LINEAR_API_KEY=lin_api_… node deploy/ctl.mjs link linear ` | `node deploy/ctl.mjs link notion ` | `GOOGLE_CLIENT_ID=… GOOGLE_CLIENT_SECRET=… node deploy/ctl.mjs link google ` | -| Lasts | until you revoke the key | 180 days, then link again (`ctl status` shows the date) | until revoked, with an *Internal* consent screen | +| A Slack person links their own | `@boxliteai /link linear` in Slack | `node deploy/ctl.mjs link notion ` | `GOOGLE_CLIENT_ID=… GOOGLE_CLIENT_SECRET=… node deploy/ctl.mjs link google ` | +| Lasts | until revoked; OAuth tokens refresh automatically | 180 days, then link again (`ctl status` shows the date) | until revoked, with an *Internal* consent screen | **Linking a Slack person** binds their own token, keyed to their Slack id — the `U…` in the log's `request from … (U…)`. The person completes any browser consent themselves, so it's their access @@ -248,8 +254,10 @@ being bound, and the operator never sees a Notion/Google token. `node deploy/ctl who's bound; `node deploy/ctl.mjs unlink ` removes one. When someone asks for a tool they haven't linked, the bot tells them to link it first and does nothing else with it. -- **Linear:** the person creates the API key on their own member, restricted to the permissions you - allow (Read plus, for changes, Create issues / Create comments — not full Write). +- **Linear:** browser linking uses Linear MCP's OAuth registration and PKCE, bound to the requester + and browser. Tokens stay on the controller; its tool allowlist still limits changes. No OAuth app + setup is needed. Existing personal API keys remain supported through + `LINEAR_API_KEY=… node deploy/ctl.mjs link linear `. - **Notion:** the login opens a browser. The person approves it as themselves; nothing to set up first. - **Google:** the Workspace MCP servers are in a [Developer Preview](https://developers.google.com/workspace/guides/configure-mcp-servers). Join it, @@ -258,7 +266,8 @@ they haven't linked, the bot tells them to link it first and does nothing else w app*. The login asks only for the scopes your tool policy needs; after you allow a new kind of change, run it again. -The logins run on your machine and hand the tokens to the controller. Your machine keeps nothing. +Terminal logins run on your machine and hand tokens to the controller. Slack's Linear login +exchanges the authorization code on the controller; neither Slack nor a session box gets tokens. **What the bot may do** is `TOOLS` in `src/policy.mjs`: reads are listed, and every other call is refused before it reaches the service, whatever Codex asks. Changes are the ones in `WRITES`: as diff --git a/src/codex.mjs b/src/codex.mjs index 6d4af5b..2fc1678 100644 --- a/src/codex.mjs +++ b/src/codex.mjs @@ -128,7 +128,9 @@ const toolsLine = (services) => { const LABELS = { linear: 'Linear', notion: 'Notion', google: 'Google Workspace' } function linkNote(linkable = []) { if (!linkable.length) return '' - return `\nThe person hasn't linked their ${and(linkable.map((n) => LABELS[n] ?? n))} yet, so you can't read it for them. If they ask you to, tell them to link their own account first — an admin runs \`node deploy/ctl.mjs link <${linkable.join('|')}> \`, and then you'll use their own access, never anyone else's.\n` + const manual = linkable.filter((n) => n !== 'linear') + const instructions = [linkable.includes('linear') ? 'For Linear, tell them to send `/link linear` in this DM and open the private authorization link.' : '', manual.length ? `For ${and(manual.map((n) => LABELS[n] ?? n))}, an admin runs \`node deploy/ctl.mjs link <${manual.join('|')}> \`.` : ''].filter(Boolean).join(' ') + return `\nThe person hasn't linked their ${and(linkable.map((n) => LABELS[n] ?? n))} yet, so you can't read it for them. ${instructions} Then you'll use their own access, never anyone else's.\n` } /** In a channel the tools aren't available — they use one person's own account, so they're DM-only. */ diff --git a/src/controller.mjs b/src/controller.mjs index d355dea..5d9c693 100644 --- a/src/controller.mjs +++ b/src/controller.mjs @@ -23,7 +23,7 @@ // BOXLITE_SECRET_SLACK_BOT / _SLACK_APP placeholders, or /slack-bot-token + // slack-app-token (ctl slack-tokens) — no restart needed // Tools optional and per person: each Slack user binds their OWN Linear / Notion / Google -// login with `ctl link `, kept under /user-logins/ +// login from Slack (/link linear) or ctl, kept under /user-logins/ // (src/userlogins.mjs). No shared bot login; none on GitHub. // Optional: BOT_LOGIN (botlite), BOXLITE_URL (https://api.boxlite.ai), PORT (8788), PUBLIC_URL // (else looked up for this box, BOXLITE_BOX_ID), VOLUME (botlite-context), SLACK_VOLUME @@ -56,6 +56,7 @@ import { webhookHandler, requestsFromWebhook } from './webhook.mjs' import { react, reply } from './reply.mjs' import { toolBroker, SERVICES } from './tools.mjs' import { userLogins } from './userlogins.mjs' +import { linearLink } from './linear-link.mjs' import { TOOLS, SLACK_PR_REPOS } from './policy.mjs' import { tally } from './slack-reply.mjs' import { slackChannel } from './slack-channel.mjs' @@ -163,12 +164,13 @@ const webhook = webhookHandler({ }) const git = gitPushHandler({ secret: jobSecret, jobs, log }) const prGrant = prGrantHandler({ secret: jobSecret, jobs, log }) // a Slack turn asking for its PR's push -// The team's tools. Each person uses their OWN login, bound with `ctl link` and kept per user +// The team's tools. Each person uses their OWN login, linked from Slack or ctl and kept per user // (userlogins.mjs), and only in a Slack DM — so the bot only ever reads what the asker can, and // there is no shared bot login for anyone to borrow. Each distinct login and how it's stored per -// user: Linear is a key, the rest OAuth. -const loginKinds = Object.fromEntries([...new Set(Object.values(SERVICES).map((s) => s.login))].map((n) => [n, n === 'linear' ? 'key' : 'oauth'])) +// user: OAuth, with legacy Linear API keys still supported. +const loginKinds = Object.fromEntries([...new Set(Object.values(SERVICES).map((s) => s.login))].map((n) => [n, n === 'linear' ? 'key-or-oauth' : 'oauth'])) const userTools = userLogins({ dir: path.join(stateDir, 'user-logins'), kinds: loginKinds }) +const linear = linearLink({ baseUrl: () => proxyUrl, userLogins: userTools, scope: TOOLS.linear.write.length ? 'read write' : 'read' }) const tools = toolBroker({ secret: jobSecret, jobs, policy: TOOLS, log }) // whose login a turn uses is set on its job /** The model and reasoning effort turns run on right now (access.mjs: /model, else the deploy's). */ @@ -191,7 +193,7 @@ const health = () => { const deaf = Date.now() - lastSlack return deaf < 10 * 60_000 ? { ok: true } : { ok: false, why: `not connected to Slack for ${Math.floor(deaf / 60_000)} minutes` } } -const proxy = createProxy({ login: chatgpt, secret: jobSecret, jobs, model: () => running().model, log, webhook, git, pr: prGrant, tools, health }) +const proxy = createProxy({ login: chatgpt, secret: jobSecret, jobs, model: () => running().model, log, webhook, git, pr: prGrant, tools, linking: linear.handle, health }) await new Promise((resolve) => proxy.listen(cfg.port, '0.0.0.0', resolve)) const proxyUrl = (env.PUBLIC_URL || (await bl.previewUrl(env.BOXLITE_BOX_ID, cfg.port)).url).replace(/\/+$/, '') @@ -527,7 +529,7 @@ onRequest = accept await status(`live as @${cfg.login}: proxy ${proxyUrl}, webhook ${proxyUrl}/webhook, ${cfg.maxConcurrent} concurrent turns, ${cfg.dailyLimit}/user/day, volume ${cfg.volume}, admins ${[...admins.values()].map((l) => `@${l}`).join(' ') || 'none'}, codex ${CODEX_VERSION}, build ${build.commit?.slice(0, 7) ?? '?'}`) /** Keep idle per-user OAuth logins alive (Notion drops one unused for 30 days), and say in the - * status how many people have linked what — the tools are per person now (`ctl link`), no shared one. */ + * status how many people have linked what — the tools are per person, with no shared login. */ async function checkTools() { await userTools.keepAlive(log) await status(`tools: per person — ${await userTools.summary()}`, 'tools') @@ -572,7 +574,7 @@ async function connectSlack() { await rename(handed, path.join(stateDir, 'slack-state.imported.json')) log(`slack: took in the Slack agent's memory: ${Object.keys(state.slack.threads).length} threads, ${state.slack.deferred.length} requests kept for us`) } - const channel = await slackChannel({ tokens, cfg, slackState: state.slack, persist, schedule, track, draining: () => draining, jobs, bl, proxyUrl, userLogins: userTools, policy: TOOLS, turnCfg, status, log, prs: slackPrs, commands: slackCommands }) + const channel = await slackChannel({ tokens, cfg, slackState: state.slack, persist, schedule, track, draining: () => draining, jobs, bl, proxyUrl, userLogins: userTools, linear, policy: TOOLS, turnCfg, status, log, prs: slackPrs, commands: slackCommands }) if (draining) return true // shutting down meanwhile: the next controller connects slackBot = channel lastSlack = Date.now() // the trial and /healthz count Slack from its start diff --git a/src/linear-link.mjs b/src/linear-link.mjs new file mode 100644 index 0000000..da52769 --- /dev/null +++ b/src/linear-link.mjs @@ -0,0 +1,103 @@ +// A Slack member's private, short-lived browser login. Only the controller sees the tokens. +import { createHash, randomBytes } from 'node:crypto' +import { mkdir, writeFile, rename } from 'node:fs/promises' +import path from 'node:path' + +const ISSUER = 'https://mcp.linear.app' +const RESOURCE = `${ISSUER}/mcp` +const PREFIX = '/link/linear' +const fresh = () => randomBytes(32).toString('base64url') + +export function linearLink({ baseUrl, userLogins, scope = 'read', fetchImpl = fetch, now = Date.now }) { + const pending = new Map() + let registration + const post = async (endpoint, body, json = false) => { + const res = await fetchImpl(`${ISSUER}/${endpoint}`, { + method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000), + headers: { 'content-type': json ? 'application/json' : 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: json ? JSON.stringify(body) : new URLSearchParams(body).toString(), + }) + if (!res.ok) throw new Error(`Linear ${endpoint}: HTTP ${res.status}`) + return res.json() + } + const prune = () => { + for (const [state, flow] of pending) if (flow.expires <= now()) pending.delete(state) + } + return { + begin(user) { + const file = userLogins.fileFor('linear', user) + const base = new URL(baseUrl()) + if (base.protocol !== 'https:') throw new Error('Linear linking requires a public HTTPS URL') + prune() + for (const [state, flow] of pending) { + if (flow.user !== user) continue + if (flow.completing) throw new Error('Linear linking is completing; try again shortly') + pending.delete(state) + } + if (pending.size >= 1000) throw new Error('Too many Linear logins in progress; try again later') + const state = fresh() + pending.set(state, { user, file, redirect: `${base.origin}${PREFIX}/callback`, browser: fresh(), verifier: fresh(), expires: now() + 600_000 }) + return `${base.origin}${PREFIX}/start?state=${state}` + }, + async handle(req, res) { + res.setHeader('cache-control', 'no-store') + res.setHeader('referrer-policy', 'no-referrer') + res.setHeader('content-security-policy', "default-src 'none'; frame-ancestors 'none'") + res.setHeader('x-content-type-options', 'nosniff') + const send = (status, text) => res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }).end(text) + const url = new URL(req.url, 'https://controller.invalid') + if (![`${PREFIX}/start`, `${PREFIX}/callback`].includes(url.pathname)) return send(404, 'Not found') + if (!['GET', 'POST'].includes(req.method)) return send(405, 'Method not allowed') + prune() + const state = url.searchParams.get('state') + const flow = pending.get(state) + if (!flow || flow.completing) return send(410, 'This link expired or was already used. Run /link linear in Slack again.') + const cookieName = `__Secure-linear-${state}` + const cookie = `${cookieName}=${flow.browser}; Secure; HttpOnly; SameSite=Lax; Path=${PREFIX}; Max-Age=600` + // A GET does not consume the link: Slack's link scanners may visit it before the person does. + if (url.pathname === `${PREFIX}/start` && req.method === 'GET') { + res.setHeader('set-cookie', cookie) + return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(`Connect Linear

Connect your Linear account

BoxLite will use your account for your requests in Slack DMs.

`) + } + if (!(req.headers.cookie ?? '').split(';').some((c) => c.trim() === `${cookieName}=${flow.browser}`)) return send(403, 'Open the private link from Slack in this browser first.') + try { + if (url.pathname === `${PREFIX}/start`) { + if (flow.started) return send(409, 'This login has already started. Run /link linear again to retry.') + flow.started = true + registration ??= post('register', { client_name: 'BoxLite for Slack', redirect_uris: [flow.redirect], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }, true).catch((e) => { registration = null; throw e }) + flow.client = await registration + if (!flow.client.client_id) throw new Error('Linear did not register a client') + const params = new URLSearchParams({ response_type: 'code', client_id: flow.client.client_id, redirect_uri: flow.redirect, state, scope, resource: RESOURCE, code_challenge_method: 'S256', code_challenge: createHash('sha256').update(flow.verifier).digest('base64url') }) + return res.writeHead(303, { location: `${ISSUER}/authorize?${params}` }).end() + } + if (req.method !== 'GET') return send(405, 'Method not allowed') + if (!flow.client) return send(400, 'Start the login from Slack first.') + flow.completing = true // claim once, before any network or disk await + res.setHeader('set-cookie', cookie.replace('Max-Age=600', 'Max-Age=0')) + if (url.searchParams.has('error')) { + pending.delete(state) + return send(400, 'Linear was not connected. Run /link linear again to retry.') + } + const code = url.searchParams.get('code') + if (!code || (url.searchParams.has('iss') && url.searchParams.get('iss') !== ISSUER)) { + pending.delete(state) + return send(400, 'Invalid Linear authorization response.') + } + const client = flow.client + const tokens = await post('token', { grant_type: 'authorization_code', code, client_id: client.client_id, ...(client.client_secret ? { client_secret: client.client_secret } : {}), redirect_uri: flow.redirect, code_verifier: flow.verifier, resource: RESOURCE }) + if (!tokens.access_token || !tokens.refresh_token || !(Number(tokens.expires_in) > 0)) throw new Error('Incomplete Linear token response') + if (pending.get(state) !== flow || flow.expires <= now()) throw new Error('Login expired') + const record = { token_endpoint: `${ISSUER}/token`, client_id: client.client_id, ...(client.client_secret ? { client_secret: client.client_secret } : {}), resource: RESOURCE, access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: now() + Number(tokens.expires_in) * 1000, linked_at: new Date(now()).toISOString() } + await mkdir(path.dirname(flow.file), { recursive: true, mode: 0o700 }) + const temp = `${flow.file}.${state}.tmp` + await writeFile(temp, JSON.stringify(record), { mode: 0o600 }) + await rename(temp, flow.file) + pending.delete(state) + return send(200, 'Linear connected. Return to Slack and ask BoxLite in a direct message.') + } catch { + pending.delete(state) + return send(502, 'Could not connect Linear. Run /link linear in Slack again to retry.') + } + }, + } +} diff --git a/src/oauth.mjs b/src/oauth.mjs index 3fbe3cb..23b06c2 100644 --- a/src/oauth.mjs +++ b/src/oauth.mjs @@ -1,11 +1,11 @@ // Logins to Linear, Notion and Google Workspace, held by the controller only. Each person binds -// their own with `ctl link` (userlogins.mjs keeps one per user); these are the building blocks. +// their own from Slack or `ctl link` (userlogins.mjs keeps one per user); these are the building blocks. // -// Linear's is an API key — nothing to refresh. Notion's and Google's are OAuth logins kept alive +// Legacy Linear API keys need no refresh. Browser connections are OAuth logins kept alive // here the way chatgpt.mjs keeps the ChatGPT one: access tokens last hours, and refresh tokens // rotate (Notion's on every refresh), so the controller is their one holder and saves each new one -// before using it. A login arrives from `ctl link notion|google` (deploy/login.mjs), done on the -// person's machine, as a file: { token_endpoint, client_id, client_secret?, resource?, +// before using it. A login arrives from browser consent (linear-link.mjs) or a terminal login +// (deploy/login.mjs), as a file: { token_endpoint, client_id, client_secret?, resource?, // access_token, refresh_token, expires_at, linked_at, max_age_days?, account? }. import { mkdir, readFile, writeFile, rename, access } from 'node:fs/promises' import path from 'node:path' @@ -55,8 +55,8 @@ export function oauthLogin({ name, file, fetchImpl = fetch, now = () => Date.now const form = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: from.refresh_token, client_id: from.client_id }) if (from.client_secret) form.set('client_secret', from.client_secret) if (from.resource) form.set('resource', from.resource) - const res = await fetchImpl(from.token_endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: form.toString() }) - if (!res.ok) throw new Error(`${name} token refresh failed: ${res.status} ${(await res.text().catch(() => '')).slice(0, 200)}`) + const res = await fetchImpl(from.token_endpoint, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000), headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: form.toString() }) + if (!res.ok) throw new Error(`${name} token refresh failed: HTTP ${res.status}`) const t = await res.json() if (tokens !== from) return // linked again meanwhile: the new login stands, this one is done if (!(await exists(live)) && !(await exists(file))) return // unlinked while refreshing: don't resurrect it diff --git a/src/proxy.mjs b/src/proxy.mjs index 9311922..724ec4e 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -18,7 +18,7 @@ const DROP_REQ = new Set(['host', 'connection', 'keep-alive', 'content-length', // fetch has already decoded the upstream body, so its encoding/length headers no longer apply. const DROP_RES = new Set(['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'keep-alive']) -export function createProxy({ login, secret, jobs, upstream = 'https://chatgpt.com', model, maxRequestsPerJob = 400, fetchImpl = fetch, log = () => {}, webhook, git, pr, tools, health = () => ({ ok: true }) }) { +export function createProxy({ login, secret, jobs, upstream = 'https://chatgpt.com', model, maxRequestsPerJob = 400, fetchImpl = fetch, log = () => {}, webhook, git, pr, tools, linking, health = () => ({ ok: true }) }) { return http.createServer(async (req, res) => { if (req.url === '/healthz') { const h = health() @@ -28,6 +28,7 @@ export function createProxy({ login, secret, jobs, upstream = 'https://chatgpt.c if (git && req.url.startsWith('/git/')) return git(req, res) // a write turn's one push (gitpush.mjs) if (pr && req.url === '/pr') return pr(req, res) // a Slack turn asking for its PR's push (prgrant.mjs) if (tools && req.url.startsWith('/mcp/')) return tools(req, res) // Linear, Notion, Google Workspace (tools.mjs) + if (linking && req.url.startsWith('/link/linear/')) return linking(req, res) if (!ALLOWED.some(([m, re]) => m === req.method && re.test(req.url))) return send(res, 404, 'not available through this proxy') const claims = verifyJobToken(secret, /^Bearer (\S+)$/.exec(req.headers.authorization || '')?.[1]) const job = claims && jobs.live.get(claims.jti) diff --git a/src/slack-channel.mjs b/src/slack-channel.mjs index be611fc..f22d7c5 100644 --- a/src/slack-channel.mjs +++ b/src/slack-channel.mjs @@ -13,7 +13,7 @@ // event once; a restart must not drop it). import { slack } from './slack.mjs' import { socketMode } from './slack-socket.mjs' -import { requestFromEvent, isHelp, displayName, threadLabel, mentionedIds, plainText, threadLine, tsBefore, permalink, attachmentPlan, size } from './slack-events.mjs' +import { requestFromEvent, isHelp, linkCommand, displayName, threadLabel, mentionedIds, plainText, threadLine, tsBefore, permalink, attachmentPlan, size } from './slack-events.mjs' import { react, reply, say, whisper, tally } from './slack-reply.mjs' import { mayUseSlack, isSlackAdmin, slackPrAllowed, prTargets } from './policy.mjs' import { parseCommand } from './access.mjs' @@ -31,7 +31,7 @@ import { slackSessionPrompt, slackFollowUpPrompt } from './codex.mjs' * — the controller's GitHub side of a PR. `commands.run(cmd, { isAdmin, who, by, reply, help, post })` * runs a command on the controller's state and posts its answer (then restarts, for a /deploy). */ -export async function slackChannel({ tokens, cfg, slackState, persist, schedule, track, draining, jobs, bl, proxyUrl, userLogins, policy, turnCfg, status, log, prs, commands }) { +export async function slackChannel({ tokens, cfg, slackState, persist, schedule, track, draining, jobs, bl, proxyUrl, userLogins, linear, policy, turnCfg, status, log, prs, commands }) { const sk = slack(tokens.bot) // Who we are is whoever the bot token belongs to: its bot user is the one people mention. const me = await sk.call('auth.test') @@ -199,6 +199,7 @@ export async function slackChannel({ tokens, cfg, slackState, persist, schedule, `• attach files — logs, screenshots, code — and I get them too (up to ${size(cfg.maxFilesBytes)} a message)`, now.ok ? `• ask me to open a PR with a change: a draft PR from my own GitHub account, into ${prTargets(now.repos)}` : `• PRs: not now — ${now.why}`, `• \`@${bot.name} help\` — this message`, + `• \`@${bot.name} /link linear\` — connect your Linear account, then use it in a DM with me`, ...(isSlackAdmin(user) ? ['', `As an admin of this workspace, you can also run me: \`@${bot.name} /model [model] [effort]\` · \`/deploy\` (put what's merged on main live) · \`/pause\` · \`/resume\` (PR writing, everywhere).`] : []), ...(cfg.slackDailyLimit ? ['', `You have ${Math.max(0, cfg.slackDailyLimit - used)} of ${cfg.slackDailyLimit} requests left today (resets at 00:00 UTC).`] : []), ].join('\n') @@ -221,6 +222,13 @@ export async function slackChannel({ tokens, cfg, slackState, persist, schedule, log(`${key}: ${who} refused — ${access.why}`) return whisper(sk, req, `Sorry, I can't take requests from you: ${access.why}.`).catch((e) => log(`${key}: ${e.message}`)) } + const service = linkCommand(req, bot) + if (service !== null) { + const text = service === 'linear' + ? `<${linear.begin(req.user)}|Connect your Linear account>. This private link expires in 10 minutes. Then ask me in a DM.` + : `Use \`@${bot.name} /link linear\` to connect your own Linear account.` + return whisper(sk, req, text) + } // Commands (`@bot /model …`) are the controller's, as on GitHub: no box, no quota, never Codex. const cmd = parseCommand(plainText(req.text, await namesOf(mentionedIds(req.text))), bot.name) if (cmd?.name === 'help' || (!cmd && isHelp(req.text, bot))) { diff --git a/src/slack-events.mjs b/src/slack-events.mjs index e09e902..57ef671 100644 --- a/src/slack-events.mjs +++ b/src/slack-events.mjs @@ -44,6 +44,13 @@ export function isHelp(text, bot) { return /^\s*\/?help[.!?]*\s*$/i.test(rest) } +/** /link is a Slack member command, including without a mention in a DM. */ +export function linkCommand(req, bot) { + const text = req.text.trim().replace(new RegExp(`^<@${escapeRe(bot.userId)}(\\|[^>]*)?>\\s*`), '') + if (!req.isDM && text === req.text.trim()) return null + return /^\/link(?:\s|$)/i.test(text) ? text.slice(5).trim().toLowerCase() : null +} + /** The name a person goes by in the workspace. */ export const displayName = (user) => user?.profile?.display_name || user?.real_name || user?.name || user?.id || 'someone' diff --git a/src/userlogins.mjs b/src/userlogins.mjs index f7cc44e..6a2c129 100644 --- a/src/userlogins.mjs +++ b/src/userlogins.mjs @@ -1,11 +1,11 @@ // Per-user tool logins: each Slack person binds their OWN Linear / Notion / Google token -// (`ctl link `), and a turn uses the REQUESTER's token — so the bot only ever +// (Slack's /link linear or `ctl link `), and a turn uses the REQUESTER's token: // reads what that person can already see, and no one's private data reaches anyone else through it. // There is no shared super-set login on the Slack side: a person who hasn't linked simply has no // tools, and the bot tells them how (codex.mjs). // -// A user's login lives under /user-logins// — a raw key for Linear, the -// OAuth JSON (plus its refreshed `.live.json`) for the rest, exactly what oauth.mjs's keyLogin and +// A user's login lives under /user-logins// — OAuth JSON (plus its refreshed +// `.live.json`), or a legacy raw Linear key, exactly what oauth.mjs's keyLogin and // oauthLogin read. Like the shared logins, the controller is their only holder; a box reaches them // only through the tool broker with its per-turn job token, never the token itself. import path from 'node:path' @@ -17,7 +17,7 @@ const USER = /^[A-Za-z0-9_-]{1,64}$/ /** * @param {string} dir /user-logins - * @param {Record} kinds login name → how it's stored (linear is a key, the rest OAuth) + * @param {Record} kinds login name → how it's stored */ export function userLogins({ dir, kinds, fetchImpl = fetch }) { const cache = new Map() @@ -28,9 +28,20 @@ export function userLogins({ dir, kinds, fetchImpl = fetch }) { } const make = (login, user) => { const file = fileFor(login, user) - return kinds[login] === 'key' - ? keyLogin(async () => (await readFile(file, 'utf8').catch(() => '')).trim() || null) - : oauthLogin({ name: login[0].toUpperCase() + login.slice(1), file, fetchImpl }) + const key = keyLogin(async () => (await readFile(file, 'utf8').catch(() => '')).trim() || null) + const oauth = oauthLogin({ name: login[0].toUpperCase() + login.slice(1), file, fetchImpl }) + if (kinds[login] !== 'key-or-oauth') return kinds[login] === 'key' ? key : oauth + let active = key + return { + async load() { + await key.load() + active = (await key.token())?.startsWith('lin_api_') ? key : oauth + return active === key ? key.ready() : oauth.load() + }, + ready: () => active.ready(), token: () => active.token(), describe: () => active.describe(), + get refresh() { return active.refresh }, + stale: () => active.stale?.() ?? false, + } } const of = (login, user) => { const k = `${login}\u0000${user}` diff --git a/test/codex.test.mjs b/test/codex.test.mjs index 65667c4..a37c62a 100644 --- a/test/codex.test.mjs +++ b/test/codex.test.mjs @@ -197,7 +197,7 @@ test('prompts: the tools, their names and whose account they use; changes only w test('Slack prompts: guide someone to link their own tool when they have not, and use only their access', () => { const p = slackSessionPrompt({ ...talk, linkable: ['linear', 'notion'] }) - assert.match(p, /hasn't linked their Linear and Notion yet[\s\S]*node deploy\/ctl\.mjs link [\s\S]*use their own access, never anyone else's/) + assert.match(p, /hasn't linked their Linear and Notion yet[\s\S]*\/link linear[\s\S]*node deploy\/ctl\.mjs link [\s\S]*use their own access, never anyone else's/) assert.doesNotMatch(slackSessionPrompt(talk), /hasn't linked their/) // nothing to link → no note assert.match(slackFollowUpPrompt({ ...talk, linkable: ['google'] }), /hasn't linked their Google Workspace yet/) assert.doesNotMatch(slackFollowUpPrompt(talk), /hasn't linked their/) diff --git a/test/linear-link.test.mjs b/test/linear-link.test.mjs new file mode 100644 index 0000000..fb5b328 --- /dev/null +++ b/test/linear-link.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createHash } from 'node:crypto' +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { linearLink } from '../src/linear-link.mjs' +import { userLogins } from '../src/userlogins.mjs' +import { createProxy } from '../src/proxy.mjs' + +async function setup(t) { + const dir = await mkdtemp(path.join(tmpdir(), 'linear-link-')) + let clock = Date.now() + let tokenResult = { access_token: 'test-access', refresh_token: 'test-refresh', expires_in: 3600 } + const calls = [] + const fetchImpl = async (url, init) => { + assert.equal(init.redirect, 'error') + calls.push({ url, body: init.body }) + return Response.json(url.endsWith('/register') ? { client_id: 'test-client' } : tokenResult) + } + const store = userLogins({ dir, kinds: { linear: 'key-or-oauth' }, fetchImpl }) + const link = linearLink({ baseUrl: () => 'https://controller.example', userLogins: store, fetchImpl, now: () => clock }) + const server = createProxy({ linking: link.handle }) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + t.after(async () => { server.close(); await rm(dir, { recursive: true, force: true }) }) + const call = (url, options = {}) => fetch(`http://127.0.0.1:${server.address().port}${new URL(url).pathname}${new URL(url).search}`, { redirect: 'manual', ...options }) + async function start(user = 'U1') { + const url = link.begin(user) + const page = await call(url) + assert.equal(page.status, 200) + assert.match(page.headers.get('set-cookie'), /Secure; HttpOnly; SameSite=Lax/) + const cookie = page.headers.get('set-cookie').split(';')[0] + const redirect = await call(url, { method: 'POST', headers: { cookie } }) + assert.equal(redirect.status, 303) + const auth = new URL(redirect.headers.get('location')) + const callback = `${auth.searchParams.get('redirect_uri')}?state=${auth.searchParams.get('state')}&code=test-code&iss=https://mcp.linear.app` + return { url, auth, callback, cookie } + } + return { link, store, start, call, calls, advance: (ms) => { clock += ms }, tokenResult: (value) => { tokenResult = value } } +} + +test('Linear browser consent binds only the requester; survives reload, refreshes and supports legacy keys', async (t) => { + const { store, start, call, calls } = await setup(t) + const { auth, callback, cookie } = await start() + assert.equal((await call(callback)).status, 403) // another browser cannot complete the flow + const connected = await call(callback, { headers: { cookie } }) + assert.equal(connected.status, 200) + assert.match(await connected.text(), /Linear connected/) + const exchange = new URLSearchParams(calls.at(-1).body) + assert.equal(auth.searchParams.get('code_challenge'), createHash('sha256').update(exchange.get('code_verifier')).digest('base64url')) + assert.equal(exchange.get('redirect_uri'), auth.searchParams.get('redirect_uri')) + assert.equal((await stat(store.fileFor('linear', 'U1'))).mode & 0o777, 0o600) + assert.equal((await store.forUser('U2')).linear.ready(), false) + const login = (await store.forUser('U1')).linear + assert.equal(await login.token(), 'test-access') + await login.refresh() + assert.equal(new URLSearchParams(calls.at(-1).body).get('grant_type'), 'refresh_token') + const reloaded = userLogins({ dir: path.dirname(path.dirname(store.fileFor('linear', 'U1'))), kinds: store.kinds }) + assert.equal(await (await reloaded.forUser('U1')).linear.token(), 'test-access') + assert.equal((await call(callback, { headers: { cookie } })).status, 410) + await writeFile(store.fileFor('linear', 'U1'), 'lin_api_test-legacy') + assert.equal(await (await store.forUser('U1')).linear.token(), 'lin_api_test-legacy') + await rm(store.fileFor('linear', 'U1')) + await rm(`${store.fileFor('linear', 'U1')}.live.json`) + assert.equal((await store.forUser('U1')).linear.ready(), false) +}) + +test('Linear links are private capabilities: bad state, cross-browser, expiry, denial and replacement fail closed', async (t) => { + const { link, store, start, call, calls, advance } = await setup(t) + const first = await start() + assert.equal((await call(first.callback.replace(/state=[^&]+/, 'state=wrong'), { headers: { cookie: first.cookie } })).status, 410) + const second = await start('U2') + assert.equal((await call(first.callback, { headers: { cookie: second.cookie } })).status, 403) + const replacement = link.begin('U1') + assert.equal((await call(first.callback, { headers: { cookie: first.cookie } })).status, 410) + assert.equal((await call(replacement, { method: 'POST' })).status, 403) + const denied = second.callback.replace('code=test-code', 'error=access_denied') + assert.equal((await call(denied, { headers: { cookie: second.cookie } })).status, 400) + assert.equal((await call(second.callback, { headers: { cookie: second.cookie } })).status, 410) + advance(600_001) + assert.equal((await call(replacement)).status, 410) + assert.equal(calls.filter((c) => c.url.endsWith('/token')).length, 0) + assert.equal((await store.forUser('U1')).linear.ready(), false) + assert.equal((await store.forUser('U2')).linear.ready(), false) +}) + +test('invalid issuer and incomplete tokens never replace a working login or leak provider errors', async (t) => { + const { store, start, call, tokenResult } = await setup(t) + const first = await start() + assert.equal((await call(first.callback.replace('iss=https://mcp.linear.app', 'iss=https://other.example'), { headers: { cookie: first.cookie } })).status, 400) + const second = await start() + await call(second.callback, { headers: { cookie: second.cookie } }) + const third = await start() + tokenResult({ error: 'test-provider-secret', access_token: 'test-rejected' }) + const failed = await call(third.callback, { headers: { cookie: third.cookie } }) + assert.equal(failed.status, 502) + assert.doesNotMatch(await failed.text(), /test-provider-secret|test-rejected/) + assert.equal(await (await store.forUser('U1')).linear.token(), 'test-access') +}) diff --git a/test/oauth.test.mjs b/test/oauth.test.mjs index 8755aac..86574c6 100644 --- a/test/oauth.test.mjs +++ b/test/oauth.test.mjs @@ -31,6 +31,7 @@ test('oauthLogin: off until linked; a token near expiry is refreshed first — o let now = T0 const calls = [] const fetchImpl = async (url, init) => { + assert.equal(init.redirect, 'error') // refresh credentials must never follow redirects calls.push({ url, form: Object.fromEntries(new URLSearchParams(init.body)) }) await new Promise((r) => setTimeout(r, 5)) return Response.json({ access_token: 'at-2', refresh_token: 'rt-2', expires_in: 28_800 }) @@ -105,7 +106,7 @@ test('oauthLogin: a failed refresh throws and keeps the login; a new login from const file = fileWith(linked()) const l = oauthLogin({ name: 'Notion', file, now: () => T0 + 7_200_000, fetchImpl: async () => new Response('{"error":"invalid_grant"}', { status: 400 }) }) await l.load() - await assert.rejects(l.token(), /Notion token refresh failed: 400 .*invalid_grant/) + await assert.rejects(l.token(), { message: 'Notion token refresh failed: HTTP 400' }) writeFileSync(file, '{"access_tok') // ctl mid-write assert.equal(await l.load(), true) writeFileSync(file, JSON.stringify(linked({ refresh_token: 'rt-new', linked_at: '2026-10-01T00:00:00.000Z', expires_at: T0 + 10 * 3_600_000 }))) diff --git a/test/slack-events.test.mjs b/test/slack-events.test.mjs index 7735c8c..3853e2c 100644 --- a/test/slack-events.test.mjs +++ b/test/slack-events.test.mjs @@ -1,11 +1,18 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { requestFromEvent, isHelp, displayName, threadLabel, mentionedIds, plainText, threadLine, tsBefore, permalink, attachmentPlan } from '../src/slack-events.mjs' +import { requestFromEvent, isHelp, linkCommand, displayName, threadLabel, mentionedIds, plainText, threadLine, tsBefore, permalink, attachmentPlan } from '../src/slack-events.mjs' const bot = { userId: 'UBOT', botId: 'BBOT', name: 'botlite' } const payload = (event, over = {}) => ({ type: 'event_callback', team_id: 'T1', event_id: 'Ev1', event, ...over }) const mention = (over = {}) => ({ type: 'app_mention', user: 'U1', text: '<@UBOT> why does CI fail?', ts: '1712345678.000100', channel: 'C1', event_ts: '1712345678.000100', ...over }) +test('/link linear is a member command in channels and DMs; other users cannot be named as targets', () => { + for (const text of ['<@UBOT> /link linear', '<@UBOT|BoxLite> /LINK LINEAR']) assert.equal(linkCommand({ text }, bot), 'linear') + assert.equal(linkCommand({ text: '/link linear', isDM: true }, bot), 'linear') + for (const text of ['/link linear', '<@OTHER> /link linear', '<@UBOT> explain /link linear', '<@UBOT> /linking']) assert.equal(linkCommand({ text }, bot), null) + assert.equal(linkCommand({ text: '<@UBOT> /link linear U2' }, bot), 'linear u2') // rejected by the command handler +}) + test('requestFromEvent: a mention in a channel starts a thread; one inside a thread continues it', () => { const top = requestFromEvent(payload(mention()), bot) assert.deepEqual(top, { diff --git a/test/slack-link.test.mjs b/test/slack-link.test.mjs new file mode 100644 index 0000000..7658a0b --- /dev/null +++ b/test/slack-link.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { slackChannel } from '../src/slack-channel.mjs' + +test('a non-admin links only themselves through a private reply, without a Codex turn or quota', async (t) => { + const calls = [] + const bound = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + const method = new URL(url).pathname.split('/').pop() + const params = Object.fromEntries(new URLSearchParams(init.body)) + calls.push({ method, params }) + const data = method === 'auth.test' ? { user_id: 'UBOT', team_id: 'T1', user: 'boxliteai' } + : method === 'users.info' ? { user: { id: params.user, team_id: 'T1', is_restricted: params.user === 'UGUEST' } } + : method === 'apps.connections.open' ? { url: 'wss://slack.example' } : {} + return Response.json({ ok: true, ...data }) + }) + let opened + const ready = new Promise((r) => { opened = r }) + const original = globalThis.WebSocket + globalThis.WebSocket = class { + constructor() { queueMicrotask(() => opened(this)) } + send() {} + close() { this.onclose?.() } + } + t.after(() => { globalThis.WebSocket = original }) + const state = { seen: new Set(), deferred: [], usage: {} } + const channel = await slackChannel({ + tokens: { bot: 'test', app: 'test' }, cfg: { boxDeleteSec: 900, slackDailyLimit: 1 }, slackState: state, + persist() {}, draining: () => false, log() {}, status() {}, + schedule: () => assert.fail('linking must not start a turn'), commands: { run: () => assert.fail('linking must not require an admin') }, + linear: { begin: (user) => { bound.push(user); return 'https://controller.example/private-link' } }, + }) + t.after(() => channel.stop()) + channel.start() + const ws = await ready + async function message(user, text, isDM = false) { + const ts = `${calls.length}.000001` + ws.onmessage({ data: JSON.stringify({ type: 'events_api', envelope_id: ts, payload: { team_id: 'T1', event: { type: isDM ? 'message' : 'app_mention', channel_type: isDM ? 'im' : 'channel', channel: isDM ? 'D1' : 'C1', user, ts, text } } }) }) + await Promise.resolve() // Socket Mode delivers after acknowledging the envelope + await channel.settle() + } + await message('U1', '<@UBOT> /link linear') + assert.deepEqual(bound, ['U1']) + assert.deepEqual(calls.at(-1), { method: 'chat.postEphemeral', params: { channel: 'C1', user: 'U1', text: '. This private link expires in 10 minutes. Then ask me in a DM.' } }) + await message('U2', '/link linear', true) + assert.deepEqual(bound, ['U1', 'U2']) + await message('U1', '<@UBOT> /link linear U2') + await message('UGUEST', '<@UBOT> /link linear') + assert.deepEqual(bound, ['U1', 'U2']) + assert.equal(calls.some((c) => c.method === 'chat.postMessage'), false) + assert.deepEqual(state.usage, {}) +})