switchroom 0.15.36 → 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.
- package/dist/agent-scheduler/index.js +81 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +371 -357
- package/dist/host-control/main.js +148 -148
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +25 -0
- package/telegram-plugin/bridge/bridge.ts +32 -0
- package/telegram-plugin/dist/bridge/bridge.js +143 -112
- package/telegram-plugin/dist/gateway/gateway.js +813 -378
- package/telegram-plugin/dist/server.js +191 -160
- package/telegram-plugin/gateway/gateway.ts +121 -3
- package/telegram-plugin/gateway/linear-activity.ts +56 -0
- package/telegram-plugin/gateway/linear-auth-watch.ts +102 -0
- package/telegram-plugin/gateway/linear-setup.ts +196 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +77 -0
- package/telegram-plugin/tests/linear-agent-setup.test.ts +132 -0
- package/telegram-plugin/tests/linear-auth-watch.test.ts +79 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +3 -1
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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<b>&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
|
+
})
|