switchroom 0.15.35 → 0.15.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -487,7 +487,10 @@ import {
487
487
  listGrantsViaBroker,
488
488
  revokeGrantViaBroker,
489
489
  } from '../../src/vault/broker/client.js'
490
- import { emitLinearAgentActivity, createLinearIssue } from './linear-activity.js'
490
+ import { emitLinearAgentActivity, createLinearIssue, buildLinearAuthDeadMessage, brokerRefreshIO, type LinearAuthDeadReason } from './linear-activity.js'
491
+ import { runLinearAgentSetup } from './linear-setup.js'
492
+ import { runLinearAuthCheck } from './linear-auth-watch.js'
493
+ import { performLinearRefresh } from '../../src/linear/oauth-refresh.js'
491
494
  import {
492
495
  approvalRequest,
493
496
  approvalConsume,
@@ -6883,6 +6886,7 @@ const ALLOWED_TOOLS = new Set([
6883
6886
  'request_secret',
6884
6887
  'linear_agent_activity',
6885
6888
  'linear_create_issue',
6889
+ 'linear_agent_setup',
6886
6890
  ])
6887
6891
 
6888
6892
  async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
@@ -6932,6 +6936,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
6932
6936
  return executeLinearAgentActivity(args)
6933
6937
  case 'linear_create_issue':
6934
6938
  return executeLinearCreateIssue(args)
6939
+ case 'linear_agent_setup':
6940
+ return executeLinearAgentSetup(args)
6935
6941
  default:
6936
6942
  throw new Error(`unknown tool: ${tool}`)
6937
6943
  }
@@ -6963,12 +6969,66 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
6963
6969
  return { content: [{ type: 'text', text: `checklist sent (id: ${sent.message_id})` }] }
6964
6970
  }
6965
6971
 
6972
+ /**
6973
+ * Per-(agent,reason) cooldown for the Linear-auth-dead operator alert. The
6974
+ * triggering 401 recurs on every Linear call once the token expires, so
6975
+ * without a cooldown the operator would be paged on every capture/activity.
6976
+ * One alert per reason per window is enough to surface the action item.
6977
+ */
6978
+ const linearAuthAlertLast = new Map<string, number>()
6979
+ const LINEAR_AUTH_ALERT_COOLDOWN_MS = 6 * 60 * 60 * 1000
6980
+
6981
+ /**
6982
+ * Surface an un-healable Linear auth failure (no refresh bundle / revoked
6983
+ * refresh token) to the operator as a Telegram message — not just a gateway
6984
+ * log line. Deduped per (agent,reason) and gated by SWITCHROOM_LINEAR_AUTH_ALERT=0.
6985
+ * Best-effort: a failed send never affects the agent's turn.
6986
+ */
6987
+ function notifyLinearAuthDead(info: { agent: string; reason: LinearAuthDeadReason; detail: string }): void {
6988
+ if (process.env.SWITCHROOM_LINEAR_AUTH_ALERT === '0') return
6989
+ const key = `${info.agent}:${info.reason}`
6990
+ const now = Date.now()
6991
+ const last = linearAuthAlertLast.get(key)
6992
+ if (last != null && now - last < LINEAR_AUTH_ALERT_COOLDOWN_MS) return
6993
+ void (async () => {
6994
+ try {
6995
+ const chatId = loadAccess().allowFrom[0]
6996
+ if (!chatId) return
6997
+ const threadId = topicForRecipient({
6998
+ recipientChatId: chatId,
6999
+ resolvedTopic: resolveAgentOutboundTopic({ kind: 'linear-auth' }) ?? chatThreadMap.get(chatId),
7000
+ supergroupChatId: resolveAgentSupergroupChatId(),
7001
+ })
7002
+ const text = buildLinearAuthDeadMessage(info.agent, info.reason)
7003
+ await swallowingApiCall(
7004
+ () =>
7005
+ bot.api.sendMessage(chatId, text, {
7006
+ parse_mode: 'HTML',
7007
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
7008
+ }),
7009
+ { chat_id: chatId, verb: 'linearAuthDead' },
7010
+ )
7011
+ // Stamp the cooldown only after a successful send so a transient
7012
+ // Telegram failure doesn't burn the 6h window (the 401 recurs and will
7013
+ // retry the page on the next Linear call).
7014
+ linearAuthAlertLast.set(key, now)
7015
+ process.stderr.write(`telegram gateway: linear auth-dead alert sent agent=${info.agent} reason=${info.reason}\n`)
7016
+ } catch {
7017
+ /* best-effort */
7018
+ }
7019
+ })()
7020
+ }
7021
+
6966
7022
  async function executeLinearAgentActivity(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
6967
- return emitLinearAgentActivity(args)
7023
+ return emitLinearAgentActivity(args, { onAuthUnrecoverable: notifyLinearAuthDead })
6968
7024
  }
6969
7025
 
6970
7026
  async function executeLinearCreateIssue(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
6971
- return createLinearIssue(args)
7027
+ return createLinearIssue(args, { onAuthUnrecoverable: notifyLinearAuthDead })
7028
+ }
7029
+
7030
+ async function executeLinearAgentSetup(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
7031
+ return runLinearAgentSetup(args)
6972
7032
  }
6973
7033
 
6974
7034
  async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
@@ -13179,6 +13239,46 @@ function resolveAgentSupergroupChatId(): string | undefined {
13179
13239
  }
13180
13240
  }
13181
13241
 
13242
+ /** Whether THIS agent has `channels.telegram.linear_agent.enabled`. Used by the
13243
+ * proactive Linear-auth watch to skip agents that aren't Linear actors. */
13244
+ function isSelfLinearAgentEnabled(): boolean {
13245
+ const agentName = process.env.SWITCHROOM_AGENT_NAME
13246
+ if (!agentName) return false
13247
+ try {
13248
+ const cfg = loadSwitchroomConfig()
13249
+ const rawAgent = cfg.agents?.[agentName]
13250
+ if (!rawAgent) return false
13251
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
13252
+ const la = (resolved.channels?.telegram as { linear_agent?: { enabled?: boolean } } | undefined)?.linear_agent
13253
+ return la?.enabled === true
13254
+ } catch {
13255
+ return false
13256
+ }
13257
+ }
13258
+
13259
+ /**
13260
+ * One proactive Linear-auth check for this agent (boot + interval). Reads the
13261
+ * refresh bundle via the broker; missing → operator alert, near-expiry →
13262
+ * proactive rotate, revoked → operator alert. Best-effort, never throws.
13263
+ * Disabled with SWITCHROOM_LINEAR_AUTH_WATCH_POLL_MS=0.
13264
+ */
13265
+ async function runLinearAuthWatch(): Promise<void> {
13266
+ const agent = process.env.SWITCHROOM_AGENT_NAME
13267
+ if (!agent) return
13268
+ const io = brokerRefreshIO(agent)
13269
+ const status = await runLinearAuthCheck({
13270
+ agent,
13271
+ linearEnabled: isSelfLinearAgentEnabled,
13272
+ readBundle: io.readBundle,
13273
+ refresh: () => performLinearRefresh(io),
13274
+ onAuthDead: notifyLinearAuthDead,
13275
+ log: (s) => process.stderr.write(s),
13276
+ })
13277
+ if (status !== 'disabled' && status !== 'fresh') {
13278
+ process.stderr.write(`telegram gateway: linear-auth-watch agent=${agent} status=${status}\n`)
13279
+ }
13280
+ }
13281
+
13182
13282
  /**
13183
13283
  * Stamp a user-facing restart reason into the clean-shutdown marker
13184
13284
  * (same file the SIGTERM handler writes to and the next session greeting
@@ -21401,6 +21501,24 @@ void (async () => {
21401
21501
  }, QUOTA_WATCH_POLL_MS).unref()
21402
21502
  }
21403
21503
 
21504
+ // Proactive Linear-auth watch (FIX 3): catch a dead/missing/near-expiry
21505
+ // Linear bundle BEFORE the agent needs Linear, instead of only on a live
21506
+ // 401. Boot run (delayed so the broker connection settles) + interval.
21507
+ // SWITCHROOM_LINEAR_AUTH_WATCH_POLL_MS=0 disables it.
21508
+ const LINEAR_AUTH_WATCH_POLL_MS = Number(process.env.SWITCHROOM_LINEAR_AUTH_WATCH_POLL_MS ?? 6 * 60 * 60_000)
21509
+ if (LINEAR_AUTH_WATCH_POLL_MS > 0) {
21510
+ setTimeout(() => {
21511
+ void runLinearAuthWatch().catch((err) => {
21512
+ process.stderr.write(`telegram gateway: linear-auth-watch initial run failed: ${err}\n`)
21513
+ })
21514
+ }, 35_000)
21515
+ setInterval(() => {
21516
+ void runLinearAuthWatch().catch((err) => {
21517
+ process.stderr.write(`telegram gateway: linear-auth-watch scheduled run failed: ${err}\n`)
21518
+ })
21519
+ }, LINEAR_AUTH_WATCH_POLL_MS).unref()
21520
+ }
21521
+
21404
21522
  // Restart-watchdog: poll systemd's NRestarts for the agent unit.
21405
21523
  // When the count ticks up without a corresponding restart-pending
21406
21524
  // marker (= user-initiated /restart), emit an operator event.
@@ -24,6 +24,37 @@ import { performLinearRefresh, type RefreshIO } from '../../src/linear/oauth-ref
24
24
 
25
25
  export const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql'
26
26
 
27
+ /** The two operator-action reasons a Linear 401 can't self-heal. */
28
+ export type LinearAuthDeadReason = 'no_bundle' | 'revoked'
29
+
30
+ /** Minimal HTML-escape (Telegram parse_mode: 'HTML'). Kept local so the
31
+ * message builder is self-contained + unit-testable without reaching into a
32
+ * gateway-only escaper (the bug that shipped the first cut of this alert). */
33
+ function escapeHtmlMin(s: string): string {
34
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
35
+ }
36
+
37
+ /**
38
+ * Build the operator-facing Telegram alert (HTML) for an un-healable Linear
39
+ * auth failure. Pure + self-escaping so it can be unit-tested directly. The
40
+ * gateway's `notifyLinearAuthDead` only handles dedup + transport.
41
+ */
42
+ export function buildLinearAuthDeadMessage(agent: string, reason: LinearAuthDeadReason): string {
43
+ const a = escapeHtmlMin(agent)
44
+ const why =
45
+ reason === 'no_bundle'
46
+ ? `no refresh credentials are stored (<code>linear/${a}/oauth</code> is missing), so its daily-expiring token can't renew`
47
+ : `its Linear refresh token was revoked`
48
+ return (
49
+ `🔑 <b>Linear auth needs you</b>\n` +
50
+ `<b>${a}</b> can't reach Linear — ${why}. ` +
51
+ `Its access token will keep failing until you re-authorize.\n\n` +
52
+ `Re-auth (actor=app) then run <code>switchroom linear-agent setup --agent ${a} ` +
53
+ `--token … --refresh-token … --client-id … --client-secret …</code> on the host, ` +
54
+ `or ask me to walk you through it.`
55
+ )
56
+ }
57
+
27
58
  export type LinearTokenResult =
28
59
  | { ok: true; token: string }
29
60
  | { ok: false; reason: 'denied' | 'unreachable' | 'not_found' | 'unknown' }
@@ -44,6 +75,14 @@ export interface LinearActivityDeps {
44
75
  defaultTeamId?: string
45
76
  /** Log sink — stderr in production. */
46
77
  log?: (line: string) => void
78
+ /** Invoked when a Linear 401 CANNOT self-heal because the situation needs
79
+ * an operator to act: `no_bundle` (no refresh credentials were ever
80
+ * stored — the silent-setup-failure case) or `revoked` (the refresh token
81
+ * itself is dead). The gateway wires this to a deduped operator-facing
82
+ * Telegram alert so a daily-expiring token stops failing invisibly. NOT
83
+ * called for transient reasons (network/http_error/bad_response) — those
84
+ * retry on their own. */
85
+ onAuthUnrecoverable?: (info: { agent: string; reason: LinearAuthDeadReason; detail: string }) => void
47
86
  }
48
87
 
49
88
  export type ToolTextResult = { content: Array<{ type: string; text: string }> }
@@ -106,6 +145,7 @@ async function linearPostWithRefresh(
106
145
  fetchImpl: typeof fetch,
107
146
  log: (s: string) => void,
108
147
  refreshIO?: (agent: string) => RefreshIO,
148
+ onAuthUnrecoverable?: (info: { agent: string; reason: LinearAuthDeadReason; detail: string }) => void,
109
149
  ): Promise<{ resp: Response; token: string }> {
110
150
  const post = (t: string) =>
111
151
  fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
@@ -125,7 +165,21 @@ async function linearPostWithRefresh(
125
165
  `telegram gateway: linear token REVOKED agent=${agent} — refresh token is dead; ` +
126
166
  `operator must re-authorize (linear-agent setup --refresh-token …)\n`,
127
167
  )
168
+ onAuthUnrecoverable?.({ agent, reason: 'revoked', detail: refreshed.detail })
169
+ } else if (refreshed.reason === 'no_bundle') {
170
+ // No refresh bundle was ever stored (the silent-setup-failure case):
171
+ // the access token expires ~daily and there is nothing to renew from.
172
+ // This is invisible in the gateway log alone — surface it to the
173
+ // operator so they can re-provision instead of the agent failing
174
+ // every day forever.
175
+ log(
176
+ `telegram gateway: linear token DEAD agent=${agent} — no refresh bundle stored ` +
177
+ `(linear/${agent}/oauth absent); operator must re-authorize\n`,
178
+ )
179
+ onAuthUnrecoverable?.({ agent, reason: 'no_bundle', detail: refreshed.detail })
128
180
  } else {
181
+ // Transient (network / http_error / bad_response): retries on its own,
182
+ // no operator action — log only, don't page.
129
183
  log(`telegram gateway: linear token refresh failed agent=${agent} reason=${refreshed.reason}\n`)
130
184
  }
131
185
  return { resp, token } // surface the original 401
@@ -206,6 +260,7 @@ export async function emitLinearAgentActivity(
206
260
  fetchImpl,
207
261
  log,
208
262
  deps.refreshIO,
263
+ deps.onAuthUnrecoverable,
209
264
  ))
210
265
  } catch (err) {
211
266
  return {
@@ -312,6 +367,7 @@ export async function createLinearIssue(
312
367
  fetchImpl,
313
368
  log,
314
369
  deps.refreshIO,
370
+ deps.onAuthUnrecoverable,
315
371
  )
316
372
  resp = out.resp
317
373
  activeToken = out.token
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Proactive Linear auth watch (FIX 3 — observability).
3
+ *
4
+ * Before this, Linear auth was only ever checked REACTIVELY: a refresh (and the
5
+ * "🔑 Linear auth needs you" operator alert) happened only when an agent made a
6
+ * live Linear call and got a 401. A linear-enabled agent that rarely calls
7
+ * Linear could therefore sit dead-auth (missing bundle / revoked refresh /
8
+ * silently-expired token) completely unnoticed until the moment it needed
9
+ * Linear.
10
+ *
11
+ * This runs a small check on boot + on an interval (mirrors quota-watch):
12
+ * - bundle missing/invalid → fire the operator alert (no_bundle) NOW.
13
+ * - bundle present + access token within the refresh skew → proactively
14
+ * rotate it (so the next real call never eats a 401), and surface a revoked
15
+ * refresh token via the operator alert.
16
+ * - bundle present + token fresh → nothing.
17
+ *
18
+ * Pure orchestration over injected deps so it is unit-testable without a broker
19
+ * or the network. The gateway wires the broker-backed deps + notifyLinearAuthDead.
20
+ */
21
+
22
+ import {
23
+ parseBundle,
24
+ needsRefresh,
25
+ type PerformRefreshResult,
26
+ } from '../../src/linear/oauth-refresh.js'
27
+
28
+ export type LinearAuthWatchStatus =
29
+ | 'disabled'
30
+ | 'fresh'
31
+ | 'no_bundle'
32
+ | 'refreshed'
33
+ | 'revoked'
34
+ | 'refresh_failed'
35
+
36
+ export interface LinearAuthWatchDeps {
37
+ agent: string
38
+ /** Whether this agent has linear_agent enabled (reads config). */
39
+ linearEnabled: () => boolean
40
+ /** Read the raw JSON bundle from linear/<agent>/oauth (broker). */
41
+ readBundle: () => Promise<string | null>
42
+ /** Rotate the token via the stored bundle (performLinearRefresh over broker). */
43
+ refresh: () => Promise<PerformRefreshResult>
44
+ /** Operator alert (gateway's notifyLinearAuthDead). */
45
+ onAuthDead: (info: { agent: string; reason: 'no_bundle' | 'revoked'; detail: string }) => void
46
+ /** Epoch seconds (injectable for tests). */
47
+ nowSec?: () => number
48
+ log?: (line: string) => void
49
+ }
50
+
51
+ /**
52
+ * One proactive check. Never throws — returns a status the caller can log.
53
+ */
54
+ export async function runLinearAuthCheck(deps: LinearAuthWatchDeps): Promise<LinearAuthWatchStatus> {
55
+ const log = deps.log ?? (() => {})
56
+ if (!deps.linearEnabled()) return 'disabled'
57
+
58
+ let raw: string | null
59
+ try {
60
+ raw = await deps.readBundle()
61
+ } catch (err) {
62
+ // A broker read failure is transient infra, not an auth problem — don't
63
+ // page the operator, just log.
64
+ log(`telegram gateway: linear-auth-watch agent=${deps.agent} bundle read error: ${(err as Error).message}\n`)
65
+ return 'refresh_failed'
66
+ }
67
+
68
+ const bundle = parseBundle(raw)
69
+ if (!bundle) {
70
+ // The silent-setup-failure case: linear_agent is enabled but no refresh
71
+ // bundle was ever stored. Surface it proactively.
72
+ log(`telegram gateway: linear-auth-watch agent=${deps.agent} — no refresh bundle (proactive)\n`)
73
+ deps.onAuthDead({ agent: deps.agent, reason: 'no_bundle', detail: 'proactive watch: linear/<agent>/oauth missing or invalid' })
74
+ return 'no_bundle'
75
+ }
76
+
77
+ const now = deps.nowSec ? deps.nowSec() : Math.floor(Date.now() / 1000)
78
+ if (!needsRefresh(bundle.expiresAt, now)) {
79
+ // Fresh, or expiry-untracked (older bundle) — the reactive-on-401 path
80
+ // covers untracked bundles; nothing to do proactively.
81
+ return 'fresh'
82
+ }
83
+
84
+ // Within the refresh skew → rotate now so the next real call never 401s.
85
+ const res = await deps.refresh()
86
+ if (res.ok) {
87
+ log(`telegram gateway: linear-auth-watch agent=${deps.agent} proactively refreshed (was near expiry)\n`)
88
+ return 'refreshed'
89
+ }
90
+ if (res.reason === 'revoked') {
91
+ log(`telegram gateway: linear-auth-watch agent=${deps.agent} refresh REVOKED (proactive)\n`)
92
+ deps.onAuthDead({ agent: deps.agent, reason: 'revoked', detail: res.detail })
93
+ return 'revoked'
94
+ }
95
+ if (res.reason === 'no_bundle') {
96
+ deps.onAuthDead({ agent: deps.agent, reason: 'no_bundle', detail: res.detail })
97
+ return 'no_bundle'
98
+ }
99
+ // Transient (network/http_error/bad_response/persist_failed) — log, don't page.
100
+ log(`telegram gateway: linear-auth-watch agent=${deps.agent} proactive refresh failed reason=${res.reason}\n`)
101
+ return 'refresh_failed'
102
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `linear_agent_setup` MCP tool — in-container, operator-approved Linear
3
+ * `actor=app` OAuth provisioning (FIX 2).
4
+ *
5
+ * Background: `switchroom linear-agent setup` is host-only (it writes the
6
+ * vault file directly with the operator passphrase). Run from inside an agent
7
+ * container it silently no-ops — there is no mounted vault and no passphrase —
8
+ * which is exactly how clerk/carrie ended up with an access token but no
9
+ * refresh bundle (a daily 401 with no self-heal). This tool gives the agent a
10
+ * sanctioned in-container path that uses ONLY operator-approved primitives:
11
+ *
12
+ * 1. `action: "authorize_url"` — pure. Returns the browser authorize URL the
13
+ * operator opens to consent. No side effects, no approval.
14
+ * 2. `action: "complete"` — exchanges the `code` from the redirect for an
15
+ * access token + refresh token, then writes BOTH
16
+ * `linear/<agent>/token` (access) and `linear/<agent>/oauth` (the durable
17
+ * refresh bundle) via the broker. Creating these NEW keys requires a
18
+ * write-grant — `vault_request_access(scope: "write")` for each, which the
19
+ * operator approves. On a vault denial the tool returns the exact
20
+ * next-step text (mirrors `linear_agent_activity`'s vault_request_access
21
+ * guidance) rather than failing opaquely.
22
+ *
23
+ * The durable `secrets[]` ACL + the `linear_agent` config block are added by
24
+ * the agent via `config_propose_edit` (also operator-approved) — see the
25
+ * returned guidance and the self-service playbook. The secret VALUES never
26
+ * pass through config (no leak); only the access token + bundle go to the
27
+ * broker, and the OAuth client_secret/code are used in-process for the
28
+ * exchange and never stored or logged.
29
+ */
30
+
31
+ import { putViaBroker, readVaultTokenFile } from '../../src/vault/broker/client.js'
32
+ import {
33
+ buildLinearAuthorizeUrl,
34
+ exchangeLinearAuthCode,
35
+ serializeBundle,
36
+ } from '../../src/linear/oauth-refresh.js'
37
+
38
+ export type ToolTextResult = { content: Array<{ type: string; text: string }> }
39
+
40
+ /** Result of a single broker put (new-key create). */
41
+ type PutOutcome = { kind: 'ok' } | { kind: 'denied'; msg: string } | { kind: 'not_found'; msg: string } | { kind: 'unreachable'; msg: string }
42
+
43
+ export interface LinearSetupDeps {
44
+ /** Agent slug (defaults to SWITCHROOM_AGENT_NAME). */
45
+ agent?: string
46
+ /** Injectable fetch (tests). */
47
+ fetchImpl?: typeof fetch
48
+ /** Write `linear/<agent>/token`. Defaults to a broker put. */
49
+ putToken?: (agent: string, accessToken: string) => Promise<PutOutcome>
50
+ /** Write `linear/<agent>/oauth` (the JSON bundle). Defaults to a broker put. */
51
+ putBundle?: (agent: string, bundleJson: string) => Promise<PutOutcome>
52
+ /** Log sink — stderr in production. NEVER receives secret values. */
53
+ log?: (line: string) => void
54
+ }
55
+
56
+ const tokenKey = (agent: string) => `linear/${agent}/token`
57
+ const bundleKey = (agent: string) => `linear/${agent}/oauth`
58
+
59
+ /** Default broker put: path-as-identity + the agent's standing write-grant
60
+ * token (so a new key authorized by `vault_request_access(write)` can be
61
+ * created). Mirrors `brokerRefreshIO` in linear-activity.ts. */
62
+ function defaultPut(agent: string, key: string, value: string): Promise<PutOutcome> {
63
+ const token = readVaultTokenFile(agent) ?? undefined
64
+ const opt = token ? { token } : {}
65
+ return putViaBroker(key, { kind: 'string', value }, opt).then((r) => {
66
+ if (r.kind === 'ok') return { kind: 'ok' as const }
67
+ if (r.kind === 'unreachable') return { kind: 'unreachable' as const, msg: r.msg }
68
+ if (r.kind === 'not_found') return { kind: 'not_found' as const, msg: r.msg }
69
+ return { kind: 'denied' as const, msg: r.msg }
70
+ })
71
+ }
72
+
73
+ function text(s: string): ToolTextResult {
74
+ return { content: [{ type: 'text', text: s }] }
75
+ }
76
+
77
+ /**
78
+ * Guidance the agent shows the operator + itself after a write is blocked
79
+ * because the key doesn't exist yet (no write-grant). This is the expected
80
+ * first-run path: the operator approves the grant, then the agent retries.
81
+ */
82
+ function writeGrantGuidance(agent: string): string {
83
+ return (
84
+ `I need write access to store the Linear credentials. Call:\n` +
85
+ `• vault_request_access(key: "${tokenKey(agent)}", scope: "write", reason: "store Linear app access token")\n` +
86
+ `• vault_request_access(key: "${bundleKey(agent)}", scope: "write", reason: "store Linear OAuth refresh bundle")\n` +
87
+ `Once the operator approves both, re-run linear_agent_setup with action "complete" (same code is single-use — if it expired, re-open the authorize URL first).`
88
+ )
89
+ }
90
+
91
+ /** Guidance for the durable config (ACL + linear_agent block) the agent emits
92
+ * after the values are stored, via the operator-approved config_propose_edit. */
93
+ function durableConfigGuidance(agent: string): string {
94
+ return (
95
+ `Stored. To make this durable (survive restarts + enable auto-refresh), propose a config edit ` +
96
+ `(config_propose_edit) that, under agents.${agent}:\n` +
97
+ ` • adds channels.telegram.linear_agent: { enabled: true, token: "vault:${tokenKey(agent)}" }\n` +
98
+ ` • adds "${tokenKey(agent)}" and "${bundleKey(agent)}" to secrets[]\n` +
99
+ `Then the operator approves it and you restart to pick up the linear_agent block.`
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Run the `linear_agent_setup` tool. Validates args, performs the requested
105
+ * step, and returns actionable MCP text. Never throws on a network/vault
106
+ * failure — returns guidance the agent can act on.
107
+ */
108
+ export async function runLinearAgentSetup(
109
+ args: Record<string, unknown>,
110
+ deps: LinearSetupDeps = {},
111
+ ): Promise<ToolTextResult> {
112
+ const log = deps.log ?? ((s) => process.stderr.write(s))
113
+ const agent = deps.agent ?? process.env.SWITCHROOM_AGENT_NAME ?? '-'
114
+ if (agent === '-' || !/^[a-z][a-z0-9_-]{0,63}$/.test(agent)) {
115
+ return text(`linear_agent_setup failed: could not resolve a valid agent name (got '${agent}').`)
116
+ }
117
+
118
+ const action = args.action as string | undefined
119
+ if (action !== 'authorize_url' && action !== 'complete') {
120
+ return text(`linear_agent_setup failed: action must be "authorize_url" or "complete".`)
121
+ }
122
+
123
+ const clientId = (args.client_id as string | undefined)?.trim()
124
+ const redirectUri = (args.redirect_uri as string | undefined)?.trim()
125
+ if (!clientId) return text('linear_agent_setup failed: client_id is required.')
126
+ if (!redirectUri || !/^https?:\/\//.test(redirectUri)) {
127
+ return text('linear_agent_setup failed: redirect_uri is required and must be an http(s) URL registered on the Linear OAuth app.')
128
+ }
129
+
130
+ if (action === 'authorize_url') {
131
+ const url = buildLinearAuthorizeUrl({ clientId, redirectUri })
132
+ return text(
133
+ `Open this URL in a browser to authorize <b>${agent}</b> as a Linear app actor (actor=app):\n\n${url}\n\n` +
134
+ `After you approve, Linear redirects to ${redirectUri}?code=… (it may show a blank/error page — that's fine). ` +
135
+ `Copy the code value from the URL bar, then run linear_agent_setup with action "complete", the same client_id + redirect_uri, ` +
136
+ `your client_secret, and that code.`,
137
+ )
138
+ }
139
+
140
+ // action === 'complete'
141
+ const clientSecret = (args.client_secret as string | undefined)?.trim()
142
+ const code = (args.code as string | undefined)?.trim()
143
+ if (!clientSecret) return text('linear_agent_setup failed: client_secret is required for action "complete".')
144
+ if (!code) return text('linear_agent_setup failed: code (from the redirect URL) is required for action "complete".')
145
+
146
+ const exchanged = await exchangeLinearAuthCode(
147
+ { clientId, clientSecret, code, redirectUri },
148
+ deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {},
149
+ )
150
+ if (!exchanged.ok) {
151
+ log(`telegram gateway: linear_agent_setup exchange failed agent=${agent} reason=${exchanged.reason}\n`)
152
+ if (exchanged.reason === 'bad_code') {
153
+ return text(
154
+ `linear_agent_setup failed: Linear rejected the authorization code (expired, already used, or wrong redirect_uri). ` +
155
+ `Re-run action "authorize_url", open the fresh URL, and copy a new code.`,
156
+ )
157
+ }
158
+ return text(`linear_agent_setup failed: token exchange ${exchanged.reason} — ${exchanged.detail}. Retry shortly.`)
159
+ }
160
+
161
+ const bundle = serializeBundle({
162
+ clientId,
163
+ clientSecret,
164
+ refreshToken: exchanged.refreshToken,
165
+ expiresAt: exchanged.expiresAt,
166
+ })
167
+
168
+ const putBundle = deps.putBundle ?? ((a, j) => defaultPut(a, bundleKey(a), j))
169
+ const putToken = deps.putToken ?? ((a, t) => defaultPut(a, tokenKey(a), t))
170
+
171
+ // Write the bundle FIRST (same ordering rationale as performLinearRefresh:
172
+ // never leave a fresh access token whose refresh bundle didn't persist).
173
+ const b = await putBundle(agent, bundle)
174
+ if (b.kind !== 'ok') {
175
+ if (b.kind === 'not_found' || b.kind === 'denied') {
176
+ return text(writeGrantGuidance(agent))
177
+ }
178
+ log(`telegram gateway: linear_agent_setup bundle write ${b.kind} agent=${agent}\n`)
179
+ return text(`linear_agent_setup failed: couldn't store the refresh bundle (broker ${b.kind}: ${b.msg}).`)
180
+ }
181
+ const t = await putToken(agent, exchanged.accessToken)
182
+ if (t.kind !== 'ok') {
183
+ if (t.kind === 'not_found' || t.kind === 'denied') {
184
+ return text(writeGrantGuidance(agent))
185
+ }
186
+ log(`telegram gateway: linear_agent_setup token write ${t.kind} agent=${agent}\n`)
187
+ return text(`linear_agent_setup failed: couldn't store the access token (broker ${t.kind}: ${t.msg}).`)
188
+ }
189
+
190
+ const hours = Math.max(1, Math.round((exchanged.expiresAt - Date.now() / 1000) / 3600))
191
+ log(`telegram gateway: linear_agent_setup stored token+bundle agent=${agent} (expires ~${hours}h)\n`)
192
+ return text(
193
+ `✅ Linear app token + refresh bundle stored for ${agent} (access token expires in ~${hours}h; it now auto-renews).\n\n` +
194
+ durableConfigGuidance(agent),
195
+ )
196
+ }
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import {
4
4
  emitLinearAgentActivity,
5
+ buildLinearAuthDeadMessage,
5
6
  type LinearTokenResult,
6
7
  } from '../gateway/linear-activity.js'
7
8
 
@@ -197,3 +198,79 @@ describe('linear_agent_activity — auto-refresh on 401 (#2298 durability)', ()
197
198
  expect(calls.length).toBe(1)
198
199
  })
199
200
  })
201
+
202
+ /** RefreshIO whose bundle is absent → performLinearRefresh returns no_bundle
203
+ * (the silent-setup-failure case clerk/carrie hit in prod). */
204
+ function noBundleRefreshIO(): RefreshIO {
205
+ return { readBundle: async () => null, writeToken: async () => {}, writeBundle: async () => {} }
206
+ }
207
+
208
+ describe('emitLinearAgentActivity — operator alert when auth is unrecoverable (FIX 1)', () => {
209
+ it('no refresh bundle → onAuthUnrecoverable(no_bundle) fires', async () => {
210
+ const { fetchImpl } = refreshAwareFetch()
211
+ const alerts: Array<{ agent: string; reason: string }> = []
212
+ const r = await emitLinearAgentActivity(
213
+ { agent_session_id: 'sess', type: 'thought', body: 'hi' },
214
+ {
215
+ agent: 'clerk',
216
+ resolveToken: okToken('lin_expired'),
217
+ fetchImpl,
218
+ refreshIO: () => noBundleRefreshIO(),
219
+ log: () => {},
220
+ onAuthUnrecoverable: (i) => alerts.push(i),
221
+ },
222
+ )
223
+ expect(r.content[0].text).toMatch(/Linear API 401/)
224
+ expect(alerts).toEqual([{ agent: 'clerk', reason: 'no_bundle', detail: expect.any(String) }])
225
+ })
226
+
227
+ it('revoked refresh token → onAuthUnrecoverable(revoked) fires', async () => {
228
+ const { fetchImpl } = refreshAwareFetch({ tokenStatus: 400, tokenBody: 'invalid_grant' })
229
+ const alerts: Array<{ agent: string; reason: string }> = []
230
+ await emitLinearAgentActivity(
231
+ { agent_session_id: 'sess', type: 'thought', body: 'hi' },
232
+ { agent: 'carrie', resolveToken: okToken('lin_dead'), fetchImpl, refreshIO: () => fakeRefreshIO().io, log: () => {}, onAuthUnrecoverable: (i) => alerts.push(i) },
233
+ )
234
+ expect(alerts.map((a) => a.reason)).toEqual(['revoked'])
235
+ })
236
+
237
+ it('transient refresh failure (HTTP 500) does NOT page the operator', async () => {
238
+ const { fetchImpl } = refreshAwareFetch({ tokenStatus: 500, tokenBody: 'upstream boom' })
239
+ const alerts: unknown[] = []
240
+ await emitLinearAgentActivity(
241
+ { agent_session_id: 'sess', type: 'thought', body: 'hi' },
242
+ { agent: 'carrie', resolveToken: okToken('lin_x'), fetchImpl, refreshIO: () => fakeRefreshIO().io, log: () => {}, onAuthUnrecoverable: (i) => alerts.push(i) },
243
+ )
244
+ expect(alerts).toEqual([])
245
+ })
246
+
247
+ it('buildLinearAuthDeadMessage: no_bundle names the missing oauth key + re-auth command', () => {
248
+ const msg = buildLinearAuthDeadMessage('clerk', 'no_bundle')
249
+ expect(msg).toMatch(/Linear auth needs you/)
250
+ expect(msg).toContain('<code>linear/clerk/oauth</code>')
251
+ expect(msg).toContain('switchroom linear-agent setup --agent clerk')
252
+ })
253
+
254
+ it('buildLinearAuthDeadMessage: revoked says the refresh token was revoked', () => {
255
+ const msg = buildLinearAuthDeadMessage('carrie', 'revoked')
256
+ expect(msg).toMatch(/refresh token was revoked/)
257
+ expect(msg).not.toContain('/oauth</code> is missing')
258
+ })
259
+
260
+ it('buildLinearAuthDeadMessage: HTML-escapes a hostile agent slug', () => {
261
+ const msg = buildLinearAuthDeadMessage('a<b>&c', 'no_bundle')
262
+ expect(msg).toContain('a&lt;b&gt;&amp;c')
263
+ expect(msg).not.toContain('a<b>&c')
264
+ })
265
+
266
+ it('successful auto-refresh does NOT page the operator', async () => {
267
+ const { fetchImpl } = refreshAwareFetch()
268
+ const alerts: unknown[] = []
269
+ const r = await emitLinearAgentActivity(
270
+ { agent_session_id: 'sess', type: 'thought', body: 'hi' },
271
+ { agent: 'carrie', resolveToken: okToken('lin_expired'), fetchImpl, refreshIO: () => fakeRefreshIO().io, log: () => {}, onAuthUnrecoverable: (i) => alerts.push(i) },
272
+ )
273
+ expect(r.content[0].text).toMatch(/emitted/)
274
+ expect(alerts).toEqual([])
275
+ })
276
+ })