Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -239,17 +245,19 @@ 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 <their Slack id>` | `node deploy/ctl.mjs link notion <their Slack id>` | `GOOGLE_CLIENT_ID=… GOOGLE_CLIENT_SECRET=… node deploy/ctl.mjs link google <their Slack id>` |
| 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 <their Slack id>` | `GOOGLE_CLIENT_ID=… GOOGLE_CLIENT_SECRET=… node deploy/ctl.mjs link google <their Slack id>` |
| 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
being bound, and the operator never sees a Notion/Google token. `node deploy/ctl.mjs links` lists
who's bound; `node deploy/ctl.mjs unlink <service> <id>` 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 <their Slack id>`.
- **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,
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('|')}> <their Slack id>\`, 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('|')}> <their Slack id>\`.` : ''].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. */
Expand Down
16 changes: 9 additions & 7 deletions src/controller.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
// BOXLITE_SECRET_SLACK_BOT / _SLACK_APP placeholders, or <state dir>/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 <service> <their slack id>`, kept under <state dir>/user-logins/
// login from Slack (/link linear) or ctl, kept under <state dir>/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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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). */
Expand All @@ -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(/\/+$/, '')

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions src/linear-link.mjs
Original file line number Diff line number Diff line change
@@ -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(`<!doctype html><meta charset="utf-8"><title>Connect Linear</title><h1>Connect your Linear account</h1><p>BoxLite will use your account for your requests in Slack DMs.</p><form method="post"><button>Continue to Linear</button></form>`)
}
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.')
}
},
}
}
Loading
Loading