switchroom 0.14.61 → 0.14.62
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/cli/switchroom.js +73 -62
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +2776 -2353
- package/telegram-plugin/gateway/auth-broker-client.ts +18 -8
- package/telegram-plugin/gateway/gateway.ts +319 -8
- package/telegram-plugin/gateway/microsoft-connect-flow.ts +226 -0
- package/telegram-plugin/gateway/with-deadline.ts +43 -0
- package/telegram-plugin/tests/microsoft-connect-flow.test.ts +185 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +22 -22
- package/telegram-plugin/tests/with-deadline.test.ts +61 -0
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { AuthBrokerClient as BrokerClient, type AddAccountCredentials } from '../../src/auth/broker/client.js'
|
|
14
|
+
import type { ProviderName } from '../../src/auth/broker/protocol.js'
|
|
14
15
|
import type { AuthBrokerClient } from './auth-command.js'
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -56,21 +57,30 @@ export async function getAuthBrokerClient(
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* Add an account via the broker. Used
|
|
60
|
-
*
|
|
61
|
-
* `auth-command.ts` deliberately
|
|
62
|
-
* is gateway-routed (not
|
|
63
|
-
* one-shot {@link BrokerClient}
|
|
64
|
-
* long-lived handle just for this verb.
|
|
60
|
+
* Add an account via the broker. Used by the `/auth add` chat flow
|
|
61
|
+
* (Anthropic) and the `/connect microsoft` device-code flow — the narrow
|
|
62
|
+
* {@link AuthBrokerClient} surface in `auth-command.ts` deliberately
|
|
63
|
+
* omits `addAccount` because the verb is gateway-routed (not
|
|
64
|
+
* handler-routed). Constructs and closes a one-shot {@link BrokerClient}
|
|
65
|
+
* so the gateway doesn't need a long-lived handle just for this verb.
|
|
66
|
+
*
|
|
67
|
+
* `provider` defaults to Anthropic (back-compat with the `/auth add`
|
|
68
|
+
* caller, which omits it). The device-code flow passes `"microsoft"`
|
|
69
|
+
* with a `MicrosoftAddAccountCredentials` payload.
|
|
65
70
|
*/
|
|
66
71
|
export async function addAccountViaBroker(
|
|
67
72
|
label: string,
|
|
68
73
|
credentials: AddAccountCredentials,
|
|
69
|
-
opts: { replace?: boolean } = {},
|
|
74
|
+
opts: { replace?: boolean; provider?: ProviderName } = {},
|
|
70
75
|
): Promise<{ label: string; expiresAt?: number }> {
|
|
71
76
|
const broker = new BrokerClient()
|
|
72
77
|
try {
|
|
73
|
-
return await broker.addAccount(
|
|
78
|
+
return await broker.addAccount(
|
|
79
|
+
label,
|
|
80
|
+
credentials,
|
|
81
|
+
opts.replace,
|
|
82
|
+
opts.provider,
|
|
83
|
+
)
|
|
74
84
|
} finally {
|
|
75
85
|
await broker.close()
|
|
76
86
|
}
|
|
@@ -116,6 +116,11 @@ import {
|
|
|
116
116
|
import type { AuthBrokerClient } from './auth-command.js'
|
|
117
117
|
import type { ListStateData } from './auth-line.js'
|
|
118
118
|
import { getAuthBrokerClient, addAccountViaBroker } from './auth-broker-client.js'
|
|
119
|
+
import {
|
|
120
|
+
pendingMicrosoftConnectFlows,
|
|
121
|
+
startMicrosoftConnect,
|
|
122
|
+
runMicrosoftConnectPoll,
|
|
123
|
+
} from './microsoft-connect-flow.js'
|
|
119
124
|
import { resolveAuthBrokerSocketPath } from '../../src/auth/broker/client.js'
|
|
120
125
|
import { createFleetFallbackGate } from '../fleet-fallback-gate.js'
|
|
121
126
|
import {
|
|
@@ -284,6 +289,7 @@ import {
|
|
|
284
289
|
obligationEscalationText,
|
|
285
290
|
} from './obligation-ledger.js'
|
|
286
291
|
import { loadObligations, persistObligations } from './obligation-store.js'
|
|
292
|
+
import { withDeadline } from './with-deadline.js'
|
|
287
293
|
import { createInboundSpool } from './inbound-spool.js'
|
|
288
294
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
289
295
|
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
@@ -1406,6 +1412,17 @@ const OBLIGATION_SWEEP_MS = 5_000
|
|
|
1406
1412
|
// attempts the gateway closes best-effort (loud log): the user is genuinely
|
|
1407
1413
|
// unreachable, so a bounded give-up beats an infinite/poison loop.
|
|
1408
1414
|
const OBLIGATION_ESCALATE_MAX = 3
|
|
1415
|
+
// Deadline for a single escalation send. grammy/fetch impose NO request timeout,
|
|
1416
|
+
// and the in-flight guard (obligationEscalateInFlight) is cleared only in the
|
|
1417
|
+
// send's `.finally` — which never runs if the send hangs. Without a bound, one
|
|
1418
|
+
// stalled send leaks the in-flight flag forever and the obligation is stuck OPEN
|
|
1419
|
+
// (never re-escalated, never closed) — the sole liveness hole a total proof
|
|
1420
|
+
// found. Racing the send against this deadline makes the wait bounded BY
|
|
1421
|
+
// CONSTRUCTION (see with-deadline.ts): the chain always settles, `.finally`
|
|
1422
|
+
// always clears the flag, and a hang becomes a bounded reject that feeds the
|
|
1423
|
+
// bounded escalate ladder to a terminal. 45s comfortably exceeds robustApiCall's
|
|
1424
|
+
// 3-attempt network backoff so a legitimate slow send isn't cut short.
|
|
1425
|
+
const OBLIGATION_ESCALATE_SEND_DEADLINE_MS = 45_000
|
|
1409
1426
|
// Durable snapshot of the open obligation set on the persistent per-agent
|
|
1410
1427
|
// volume (STATE_DIR = /state/agent/telegram in prod). Closes the restart hole:
|
|
1411
1428
|
// the in-memory ledger alone empties on restart and the spool's boot-replay
|
|
@@ -3745,6 +3762,17 @@ const pendingStateReaper = setInterval(() => {
|
|
|
3745
3762
|
for (const [k, v] of awaitingAuthCodeAt) {
|
|
3746
3763
|
if (now - v > AUTH_CODE_CONTEXT_TTL_MS) awaitingAuthCodeAt.delete(k)
|
|
3747
3764
|
}
|
|
3765
|
+
// Microsoft connect flows self-expire at the device code's own expiry
|
|
3766
|
+
// (~15 min) — sweep past that + grace so an abandoned card doesn't pin
|
|
3767
|
+
// its key. Setting cancelled makes any still-running poll bail. Placed
|
|
3768
|
+
// AFTER the OAuth-code cluster above, which secret-detect-oauth-code.
|
|
3769
|
+
// test.ts pins as contiguous within the first 800 chars of the reaper.
|
|
3770
|
+
for (const [k, v] of pendingMicrosoftConnectFlows) {
|
|
3771
|
+
if (now - v.startedAt > v.device.expires_in * 1000 + 30_000) {
|
|
3772
|
+
v.cancelled = true
|
|
3773
|
+
pendingMicrosoftConnectFlows.delete(k)
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3748
3776
|
// Auth-refresh throttle entries decay quickly (5s window); sweep
|
|
3749
3777
|
// anything older than 60s so abandoned snapshot messages don't pin
|
|
3750
3778
|
// their key forever.
|
|
@@ -4874,16 +4902,39 @@ const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
|
|
|
4874
4902
|
// OPEN via meta.source; reuses tested delivery). Bounded: after maxRepresents,
|
|
4875
4903
|
// escalate to ONE operator-visible "did I miss this?" and close — no loop.
|
|
4876
4904
|
// No-op unless the flag is on; gated on the same idle predicate as the drains.
|
|
4905
|
+
// DETERMINISM (closed-form, not sampled). The obligation ledger itself
|
|
4906
|
+
// (obligation-ledger.ts) is a finite FSM with a total transition function and a
|
|
4907
|
+
// strictly-decreasing measure μ = (REPRESENT_MAX - representCount) +
|
|
4908
|
+
// (ESCALATE_MAX - escalateAttempts): every markRepresented/markEscalateAttempt
|
|
4909
|
+
// decreases μ, both terms are bounded below, and at the floor the only move is
|
|
4910
|
+
// close → terminal. So on the FSM, every OPEN reaches answered | escalation-
|
|
4911
|
+
// delivered | bounded give-up (no cycle re-increments a counter). This sweep is
|
|
4912
|
+
// the FSM's driver; its termination rests on three liveness facts, all bounded:
|
|
4913
|
+
// (1) the 5s setInterval keeps firing;
|
|
4914
|
+
// (2) the gate (turnInFlightForGate) opens — claudeBusyKeys is cleared at
|
|
4915
|
+
// turn-end (purgeReactionTracking / releaseTurnBufferGate), on bridge
|
|
4916
|
+
// disconnect (disconnect-flush.ts), and by the 300s silence-poke watchdog;
|
|
4917
|
+
// (3) the escalation send settles — bounded BY CONSTRUCTION via withDeadline
|
|
4918
|
+
// below (grammy has no request timeout, so an unbounded send was the one
|
|
4919
|
+
// way an obligation could get stuck OPEN forever — now closed).
|
|
4920
|
+
// The only residual liveness assumption is the bridge eventually reconnecting /
|
|
4921
|
+
// the process restarting, which the entire gateway's inbound delivery already
|
|
4922
|
+
// depends on and which durable spool + boot-replay make self-healing.
|
|
4877
4923
|
function obligationSweep(): void {
|
|
4878
4924
|
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
4879
4925
|
if (!obligationLedger.hasOpen()) return
|
|
4880
4926
|
if (turnInFlightForGate()) return // a turn is running — let it finish/answer
|
|
4881
4927
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
4882
|
-
if (pendingInboundBuffer.depth(agent) > 0) return // existing drain runs first; avoids double-present
|
|
4883
4928
|
const decision = obligationLedger.decideAtIdle()
|
|
4884
4929
|
const o = decision.obligation
|
|
4885
4930
|
if (decision.action === 'none' || o == null) return
|
|
4886
4931
|
if (decision.action === 'represent') {
|
|
4932
|
+
// Re-present goes through the bridge → buffer. Only the represent path is
|
|
4933
|
+
// gated on an empty buffer (let the existing drain run first, avoid
|
|
4934
|
+
// double-presenting). Escalation below is NOT gated on the buffer — it is a
|
|
4935
|
+
// direct Telegram send, independent of the bridge, so a represent stranded
|
|
4936
|
+
// behind a dead bridge can never block the operator nudge.
|
|
4937
|
+
if (pendingInboundBuffer.depth(agent) > 0) return
|
|
4887
4938
|
pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()))
|
|
4888
4939
|
const attempt = obligationLedger.markRepresented(o.originTurnId)
|
|
4889
4940
|
process.stderr.write(
|
|
@@ -4909,13 +4960,22 @@ function obligationSweep(): void {
|
|
|
4909
4960
|
// retryWithThreadFallback: a stale/renumbered topic returns THREAD_NOT_FOUND;
|
|
4910
4961
|
// retry WITHOUT the thread so the nudge still lands in the chat (the #2096
|
|
4911
4962
|
// pattern) instead of being permanently undeliverable to a dead topic.
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4963
|
+
// withDeadline: grammy/fetch impose no request timeout and `.finally` (which
|
|
4964
|
+
// clears the in-flight flag) only runs on settle — so a hung send would leak
|
|
4965
|
+
// the flag forever and wedge this obligation OPEN. Racing against a deadline
|
|
4966
|
+
// guarantees the chain settles, the flag always clears, and a hang becomes a
|
|
4967
|
+
// bounded reject handled exactly like any other failed attempt.
|
|
4968
|
+
void withDeadline(
|
|
4969
|
+
retryWithThreadFallback(
|
|
4970
|
+
robustApiCall,
|
|
4971
|
+
(tid) =>
|
|
4972
|
+
bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
|
|
4973
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
4974
|
+
}),
|
|
4975
|
+
{ threadId: o.threadId, chat_id: o.chatId, verb: 'obligation.escalate' },
|
|
4976
|
+
),
|
|
4977
|
+
OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
|
|
4978
|
+
'obligation escalation send timed out',
|
|
4919
4979
|
)
|
|
4920
4980
|
.then(() => {
|
|
4921
4981
|
obligationLedger.close(escId)
|
|
@@ -14385,6 +14445,226 @@ async function runQuotaWatch(): Promise<void> {
|
|
|
14385
14445
|
}
|
|
14386
14446
|
}
|
|
14387
14447
|
|
|
14448
|
+
/**
|
|
14449
|
+
* Edit a Microsoft connect card from the BACKGROUND device-code poll
|
|
14450
|
+
* (no `ctx` — we hold the chat+message id). Wrapped in robustApiCall;
|
|
14451
|
+
* swallows failures (a deleted card / closed topic must not crash the
|
|
14452
|
+
* poll). RFC #1873 Phase 2.
|
|
14453
|
+
*/
|
|
14454
|
+
async function editMicrosoftConnectCard(
|
|
14455
|
+
chatId: number | string,
|
|
14456
|
+
messageId: number,
|
|
14457
|
+
html: string,
|
|
14458
|
+
): Promise<void> {
|
|
14459
|
+
await robustApiCall(
|
|
14460
|
+
// allow-raw-bot-api: background connect-card edit by message id (no thread_id; not the reply chunk loop)
|
|
14461
|
+
() => bot.api.editMessageText(chatId, messageId, html, {
|
|
14462
|
+
parse_mode: 'HTML',
|
|
14463
|
+
link_preview_options: { is_disabled: true },
|
|
14464
|
+
}),
|
|
14465
|
+
{ chat_id: String(chatId), verb: 'microsoftConnectCard' },
|
|
14466
|
+
).catch(() => {})
|
|
14467
|
+
}
|
|
14468
|
+
|
|
14469
|
+
/**
|
|
14470
|
+
* Background half of `/connect microsoft`: poll Microsoft for consent,
|
|
14471
|
+
* register the account with the broker, then edit the card to the
|
|
14472
|
+
* outcome. Fire-and-forget from the command handler.
|
|
14473
|
+
*/
|
|
14474
|
+
async function finalizeMicrosoftConnect(key: string): Promise<void> {
|
|
14475
|
+
const flow = pendingMicrosoftConnectFlows.get(key)
|
|
14476
|
+
if (!flow) return
|
|
14477
|
+
const agentName = getMyAgentName()
|
|
14478
|
+
const result = await runMicrosoftConnectPoll(flow)
|
|
14479
|
+
// A `/connect cancel` (command or button) between consent and write
|
|
14480
|
+
// already edited the card and dropped the entry — don't clobber it.
|
|
14481
|
+
if (result.kind === 'cancelled' || !pendingMicrosoftConnectFlows.has(key)) {
|
|
14482
|
+
pendingMicrosoftConnectFlows.delete(key)
|
|
14483
|
+
return
|
|
14484
|
+
}
|
|
14485
|
+
pendingMicrosoftConnectFlows.delete(key)
|
|
14486
|
+
|
|
14487
|
+
let html: string
|
|
14488
|
+
if (result.kind === 'connected') {
|
|
14489
|
+
html =
|
|
14490
|
+
`✅ <b>Connected</b> <code>${escapeHtmlForTg(result.account)}</code> ` +
|
|
14491
|
+
`(${result.accountType}).\n\n` +
|
|
14492
|
+
`To let <b>${escapeHtmlForTg(agentName)}</b> use it, run on the host:\n` +
|
|
14493
|
+
`<code>switchroom auth microsoft enable ${escapeHtmlForTg(result.account)} ${escapeHtmlForTg(agentName)}</code>\n` +
|
|
14494
|
+
`then restart ${escapeHtmlForTg(agentName)}.`
|
|
14495
|
+
} else if (result.kind === 'no-refresh-token') {
|
|
14496
|
+
html =
|
|
14497
|
+
`⚠ Microsoft returned no refresh token (the account would expire in ~1h), ` +
|
|
14498
|
+
`so it was not registered. Try <code>/connect microsoft</code> again, or ` +
|
|
14499
|
+
`connect from the host CLI.`
|
|
14500
|
+
} else {
|
|
14501
|
+
html =
|
|
14502
|
+
`❌ <b>Connect failed:</b> ${escapeHtmlForTg(result.message)}\n\n` +
|
|
14503
|
+
`<i>Work/school accounts can't use the phone flow at /common — connect those ` +
|
|
14504
|
+
`from the host: <code>switchroom auth microsoft account add <email></code>.</i>`
|
|
14505
|
+
}
|
|
14506
|
+
await editMicrosoftConnectCard(flow.cardChatId, flow.cardMessageId, html)
|
|
14507
|
+
}
|
|
14508
|
+
|
|
14509
|
+
/**
|
|
14510
|
+
* `/connect microsoft` — Telegram-native device-code connect for a
|
|
14511
|
+
* Microsoft account (RFC #1873 Phase 2). The user signs in on their
|
|
14512
|
+
* phone; we register the account with the auth-broker (shipped default
|
|
14513
|
+
* app unless the operator BYO'd one). Admin-gated like `/auth add`.
|
|
14514
|
+
* `/connect cancel` aborts a pending flow. Google stays host-CLI.
|
|
14515
|
+
*/
|
|
14516
|
+
bot.command('connect', async ctx => {
|
|
14517
|
+
// Credential-plane admin is OPERATOR-PRIVATE (WS7-F2 / #1408), exactly
|
|
14518
|
+
// like `/auth`: honor `/connect` ONLY in a private chat from a strict
|
|
14519
|
+
// `access.allowFrom` sender — never the group-permissive
|
|
14520
|
+
// `isAuthorizedSender` (an empty group `allowFrom` = allow-all, so a
|
|
14521
|
+
// forum member could otherwise bind THEIR OWN Microsoft account as the
|
|
14522
|
+
// agent's credential). The agent-`admin:true` flag check below is
|
|
14523
|
+
// orthogonal and the broker enforces it server-side on add-account.
|
|
14524
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
14525
|
+
const operatorPrivate =
|
|
14526
|
+
ctx.chat?.type === 'private' && loadAccess().allowFrom.includes(senderId)
|
|
14527
|
+
if (!operatorPrivate) {
|
|
14528
|
+
if (ctx.chat?.type !== 'private') {
|
|
14529
|
+
process.stderr.write(
|
|
14530
|
+
`telegram gateway: /connect refused (not operator-private) agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} chat=${ctx.chat?.type ?? '?'} sender=${senderId}\n`,
|
|
14531
|
+
)
|
|
14532
|
+
await switchroomReply(
|
|
14533
|
+
ctx,
|
|
14534
|
+
`⚠️ <code>/connect</code> links account credentials — it is <b>operator-private</b>. ` +
|
|
14535
|
+
`Send it as a direct message to me from your operator account (a private chat where ` +
|
|
14536
|
+
`your Telegram ID is on the access allowlist), not in a group or forum.`,
|
|
14537
|
+
{ html: true },
|
|
14538
|
+
).catch(() => {})
|
|
14539
|
+
}
|
|
14540
|
+
return
|
|
14541
|
+
}
|
|
14542
|
+
|
|
14543
|
+
const arg = String(ctx.match ?? '').trim().toLowerCase()
|
|
14544
|
+
const chatId = String(ctx.chat?.id ?? '')
|
|
14545
|
+
const key = chatKey(chatId, ctx.message?.message_thread_id ?? null) as string
|
|
14546
|
+
|
|
14547
|
+
// Agent-`admin:true` gate (orthogonal to operator-private above; same
|
|
14548
|
+
// source as /auth + the broker's server-side add-account enforcement).
|
|
14549
|
+
let isAdmin = false
|
|
14550
|
+
try {
|
|
14551
|
+
const cfg = loadSwitchroomConfig()
|
|
14552
|
+
const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean }> })
|
|
14553
|
+
?.agents?.[getMyAgentName()]
|
|
14554
|
+
isAdmin = me?.admin === true
|
|
14555
|
+
} catch { /* non-admin is the safe default */ }
|
|
14556
|
+
if (!isAuthAdmin({ isAdmin })) {
|
|
14557
|
+
await switchroomReply(
|
|
14558
|
+
ctx,
|
|
14559
|
+
`<b>Not authorized.</b> <code>/connect</code> requires this agent to have ` +
|
|
14560
|
+
`<code>admin: true</code> in switchroom.yaml.`,
|
|
14561
|
+
{ html: true },
|
|
14562
|
+
)
|
|
14563
|
+
return
|
|
14564
|
+
}
|
|
14565
|
+
|
|
14566
|
+
if (arg === 'cancel') {
|
|
14567
|
+
const existing = pendingMicrosoftConnectFlows.get(key)
|
|
14568
|
+
if (!existing) {
|
|
14569
|
+
await switchroomReply(ctx, '<i>No pending connect flow in this chat.</i>', { html: true })
|
|
14570
|
+
return
|
|
14571
|
+
}
|
|
14572
|
+
existing.cancelled = true
|
|
14573
|
+
pendingMicrosoftConnectFlows.delete(key)
|
|
14574
|
+
await switchroomReply(ctx, 'Cancelled.', { html: true })
|
|
14575
|
+
return
|
|
14576
|
+
}
|
|
14577
|
+
|
|
14578
|
+
if (arg !== '' && arg !== 'microsoft' && arg !== 'ms') {
|
|
14579
|
+
await switchroomReply(
|
|
14580
|
+
ctx,
|
|
14581
|
+
`<b>Usage:</b> <code>/connect microsoft</code> to link a Microsoft account ` +
|
|
14582
|
+
`(Outlook / Office 365), or <code>/connect cancel</code>.\n` +
|
|
14583
|
+
`<i>Google accounts are connected from the host CLI.</i>`,
|
|
14584
|
+
{ html: true },
|
|
14585
|
+
)
|
|
14586
|
+
return
|
|
14587
|
+
}
|
|
14588
|
+
|
|
14589
|
+
if (pendingMicrosoftConnectFlows.has(key)) {
|
|
14590
|
+
await switchroomReply(
|
|
14591
|
+
ctx,
|
|
14592
|
+
'<i>A Microsoft connect flow is already in progress here. Finish it on your phone, ' +
|
|
14593
|
+
'or send <code>/connect cancel</code>.</i>',
|
|
14594
|
+
{ html: true },
|
|
14595
|
+
)
|
|
14596
|
+
return
|
|
14597
|
+
}
|
|
14598
|
+
|
|
14599
|
+
let configClientId: string | undefined
|
|
14600
|
+
let orgMode = false
|
|
14601
|
+
try {
|
|
14602
|
+
const cfg = loadSwitchroomConfig() as unknown as {
|
|
14603
|
+
microsoft_workspace?: { microsoft_client_id?: string; org_mode?: boolean }
|
|
14604
|
+
}
|
|
14605
|
+
configClientId = cfg?.microsoft_workspace?.microsoft_client_id
|
|
14606
|
+
orgMode = cfg?.microsoft_workspace?.org_mode === true
|
|
14607
|
+
} catch { /* fall back to the shipped default */ }
|
|
14608
|
+
|
|
14609
|
+
const started = await startMicrosoftConnect({ configClientId, orgMode })
|
|
14610
|
+
if (started.kind === 'byo-vault') {
|
|
14611
|
+
await switchroomReply(
|
|
14612
|
+
ctx,
|
|
14613
|
+
`<b>Can't connect from chat:</b> this install uses a vaulted custom Microsoft ` +
|
|
14614
|
+
`app (<code>${escapeHtmlForTg(started.ref)}</code>) only the host CLI can read. ` +
|
|
14615
|
+
`Run <code>switchroom auth microsoft account add <email></code> on the host.`,
|
|
14616
|
+
{ html: true },
|
|
14617
|
+
)
|
|
14618
|
+
return
|
|
14619
|
+
}
|
|
14620
|
+
if (started.kind === 'error') {
|
|
14621
|
+
await switchroomReply(
|
|
14622
|
+
ctx,
|
|
14623
|
+
`<b>/connect failed:</b> ${escapeHtmlForTg(started.message)}`,
|
|
14624
|
+
{ html: true },
|
|
14625
|
+
)
|
|
14626
|
+
return
|
|
14627
|
+
}
|
|
14628
|
+
|
|
14629
|
+
const appNote =
|
|
14630
|
+
started.source === 'default'
|
|
14631
|
+
? "<i>Using switchroom's shipped Microsoft app.</i>"
|
|
14632
|
+
: '<i>Using your configured Microsoft app.</i>'
|
|
14633
|
+
const keyboard = new InlineKeyboard()
|
|
14634
|
+
.url('🔐 Sign in to Microsoft', started.device.verification_uri)
|
|
14635
|
+
.row()
|
|
14636
|
+
.text('✖ Cancel', `cn:cancel:${key}`)
|
|
14637
|
+
const sent = await ctx.reply(
|
|
14638
|
+
`🔗 <b>Connect a Microsoft account</b>\n\n` +
|
|
14639
|
+
`1. Tap <b>Sign in to Microsoft</b> below.\n` +
|
|
14640
|
+
`2. Enter this code: <code>${escapeHtmlForTg(started.device.user_code)}</code>\n` +
|
|
14641
|
+
`3. Approve the requested permissions (Mail, Calendar, Files).\n\n` +
|
|
14642
|
+
`I'll confirm here once it's connected (within ~15 min).\n${appNote}`,
|
|
14643
|
+
{
|
|
14644
|
+
parse_mode: 'HTML',
|
|
14645
|
+
link_preview_options: { is_disabled: true },
|
|
14646
|
+
reply_markup: keyboard,
|
|
14647
|
+
...(ctx.message?.message_thread_id != null
|
|
14648
|
+
? { message_thread_id: ctx.message.message_thread_id }
|
|
14649
|
+
: {}),
|
|
14650
|
+
},
|
|
14651
|
+
)
|
|
14652
|
+
|
|
14653
|
+
pendingMicrosoftConnectFlows.set(key, {
|
|
14654
|
+
initiatedBy: String(ctx.from?.id ?? ''),
|
|
14655
|
+
cardChatId: ctx.chat!.id,
|
|
14656
|
+
cardMessageId: sent.message_id,
|
|
14657
|
+
device: started.device,
|
|
14658
|
+
clientId: started.clientId,
|
|
14659
|
+
scopes: started.scopes,
|
|
14660
|
+
startedAt: Date.now(),
|
|
14661
|
+
cancelled: false,
|
|
14662
|
+
})
|
|
14663
|
+
|
|
14664
|
+
// Background: poll Microsoft, register the account, edit the card.
|
|
14665
|
+
void finalizeMicrosoftConnect(key)
|
|
14666
|
+
})
|
|
14667
|
+
|
|
14388
14668
|
bot.command("auth", async ctx => {
|
|
14389
14669
|
// sec WS7-F2b (#1394): `/auth` drives the auth-broker credential
|
|
14390
14670
|
// lifecycle (`/auth add` mints/attaches an Anthropic account token,
|
|
@@ -17130,6 +17410,37 @@ bot.on('callback_query:data', async ctx => {
|
|
|
17130
17410
|
return
|
|
17131
17411
|
}
|
|
17132
17412
|
|
|
17413
|
+
// `cn:cancel:<key>` — cancel a pending Microsoft connect flow (the
|
|
17414
|
+
// Cancel button on the /connect card). RFC #1873 Phase 2.
|
|
17415
|
+
if (data.startsWith('cn:')) {
|
|
17416
|
+
const access = loadAccess()
|
|
17417
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
17418
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
17419
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
17420
|
+
return
|
|
17421
|
+
}
|
|
17422
|
+
const rest = data.slice('cn:'.length)
|
|
17423
|
+
const sep = rest.indexOf(':')
|
|
17424
|
+
const action = sep >= 0 ? rest.slice(0, sep) : rest
|
|
17425
|
+
const flowKey = sep >= 0 ? rest.slice(sep + 1) : ''
|
|
17426
|
+
if (action === 'cancel') {
|
|
17427
|
+
const pending = pendingMicrosoftConnectFlows.get(flowKey)
|
|
17428
|
+
if (pending) {
|
|
17429
|
+
pending.cancelled = true
|
|
17430
|
+
pendingMicrosoftConnectFlows.delete(flowKey)
|
|
17431
|
+
}
|
|
17432
|
+
await ctx.answerCallbackQuery({ text: 'Connect cancelled.' })
|
|
17433
|
+
await ctx
|
|
17434
|
+
.editMessageText('Microsoft connect cancelled.', {
|
|
17435
|
+
reply_markup: { inline_keyboard: [] },
|
|
17436
|
+
})
|
|
17437
|
+
.catch(() => {})
|
|
17438
|
+
} else {
|
|
17439
|
+
await ctx.answerCallbackQuery()
|
|
17440
|
+
}
|
|
17441
|
+
return
|
|
17442
|
+
}
|
|
17443
|
+
|
|
17133
17444
|
// RFC B §6.1: apv:<request_id>:<choice>[:<param>] — approval kernel taps.
|
|
17134
17445
|
// Routed through the generic kernel handler so any surface that uses
|
|
17135
17446
|
// buildApprovalCard inherits consume → record → confirmation UX without
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram-native Microsoft connect — device-code flow (RFC #1873 /
|
|
3
|
+
* out-of-box, Phase 2).
|
|
4
|
+
*
|
|
5
|
+
* The headline "connect from your phone" path: a user runs
|
|
6
|
+
* `/connect microsoft`, the gateway shows a card with a Microsoft
|
|
7
|
+
* sign-in link + a short code, the user approves on their phone, and the
|
|
8
|
+
* gateway registers the resulting account with the auth-broker — no host
|
|
9
|
+
* shell, no Azure portal (the shipped default app is used unless the
|
|
10
|
+
* operator BYO'd one).
|
|
11
|
+
*
|
|
12
|
+
* This module is the framework-agnostic core: it talks to Microsoft's
|
|
13
|
+
* device-code endpoints (RFC 8628, engine in `src/microsoft/oauth.ts`)
|
|
14
|
+
* and the auth-broker, and returns plain data. The gateway owns the
|
|
15
|
+
* Telegram surface (card rendering, edits, callbacks). All network +
|
|
16
|
+
* broker boundaries are injectable so the flow is testable without
|
|
17
|
+
* hitting Microsoft or a live broker, and it contains NO raw bot.api
|
|
18
|
+
* calls (the bot-api-wrapping lint trap lives only in the gateway).
|
|
19
|
+
*
|
|
20
|
+
* Mirrors `auth-add-flow.ts` (the Anthropic `/auth add` template) but
|
|
21
|
+
* needs no child subprocess and no pasted code: device-code consent
|
|
22
|
+
* happens entirely on Microsoft's domain, so nothing secret is ever
|
|
23
|
+
* pasted into chat (strictly better than paste-back — no redaction
|
|
24
|
+
* needed). Personal Microsoft accounts (outlook.com/hotmail) are the
|
|
25
|
+
* clean case at `/common`; a work/school account that fails device-code
|
|
26
|
+
* at `/common` surfaces a clear "use the host CLI" error (the documented
|
|
27
|
+
* "personal-first, work best-effort" boundary).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
requestDeviceCode as realRequestDeviceCode,
|
|
32
|
+
pollDeviceToken as realPollDeviceToken,
|
|
33
|
+
type MicrosoftDeviceCodeResponse,
|
|
34
|
+
type MicrosoftOAuthClientConfig,
|
|
35
|
+
} from '../../src/microsoft/oauth.js'
|
|
36
|
+
import { selectMicrosoftScopes } from '../../src/microsoft/scopes.js'
|
|
37
|
+
import { buildMicrosoftCredentials } from '../../src/microsoft/credentials.js'
|
|
38
|
+
import { resolveMicrosoftClientId } from '../../src/auth/default-oauth-clients.js'
|
|
39
|
+
import { isVaultReference } from '../../src/vault/resolver.js'
|
|
40
|
+
import { addAccountViaBroker } from './auth-broker-client.js'
|
|
41
|
+
import type { MicrosoftAddAccountCredentials } from '../../src/auth/broker/client.js'
|
|
42
|
+
|
|
43
|
+
/** A connect flow in flight, keyed by `chatKey(chatId, threadId)`. */
|
|
44
|
+
export interface PendingMicrosoftConnectFlow {
|
|
45
|
+
/** Telegram user id that started the flow (consent owner; Phase 3). */
|
|
46
|
+
initiatedBy: string
|
|
47
|
+
/** Card we posted, so the poll loop can edit it on completion. */
|
|
48
|
+
cardChatId: number | string
|
|
49
|
+
cardMessageId: number
|
|
50
|
+
device: MicrosoftDeviceCodeResponse
|
|
51
|
+
clientId: string
|
|
52
|
+
scopes: string[]
|
|
53
|
+
startedAt: number
|
|
54
|
+
/** Flipped by cancel so the in-flight poll bails without writing. */
|
|
55
|
+
cancelled: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const pendingMicrosoftConnectFlows = new Map<
|
|
59
|
+
string,
|
|
60
|
+
PendingMicrosoftConnectFlow
|
|
61
|
+
>()
|
|
62
|
+
|
|
63
|
+
export interface MicrosoftConnectDeps {
|
|
64
|
+
/** `config.microsoft_workspace?.microsoft_client_id` (may be a vault: ref). */
|
|
65
|
+
configClientId?: string
|
|
66
|
+
orgMode?: boolean
|
|
67
|
+
requestDeviceCode?: (
|
|
68
|
+
cfg: MicrosoftOAuthClientConfig,
|
|
69
|
+
) => Promise<MicrosoftDeviceCodeResponse>
|
|
70
|
+
pollDeviceToken?: typeof realPollDeviceToken
|
|
71
|
+
addAccount?: (
|
|
72
|
+
label: string,
|
|
73
|
+
credentials: MicrosoftAddAccountCredentials,
|
|
74
|
+
opts: { replace?: boolean; provider: 'microsoft' },
|
|
75
|
+
) => Promise<{ label: string; expiresAt?: number }>
|
|
76
|
+
now?: () => number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type StartResult =
|
|
80
|
+
| {
|
|
81
|
+
kind: 'started'
|
|
82
|
+
device: MicrosoftDeviceCodeResponse
|
|
83
|
+
clientId: string
|
|
84
|
+
scopes: string[]
|
|
85
|
+
/** 'default' = shipped app; 'config'/'env' = BYO. */
|
|
86
|
+
source: 'env' | 'config' | 'default'
|
|
87
|
+
}
|
|
88
|
+
| {
|
|
89
|
+
// The operator BYO'd a Microsoft client via a vault: reference,
|
|
90
|
+
// which the gateway can't resolve in-process — host CLI only.
|
|
91
|
+
kind: 'byo-vault'
|
|
92
|
+
ref: string
|
|
93
|
+
}
|
|
94
|
+
| { kind: 'error'; message: string }
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Request a device code and return the data the gateway needs to render
|
|
98
|
+
* the connect card. Does NOT mutate the pending map — the gateway stores
|
|
99
|
+
* the pending entry (with the card message id) after it posts the card.
|
|
100
|
+
*/
|
|
101
|
+
export async function startMicrosoftConnect(
|
|
102
|
+
deps: MicrosoftConnectDeps = {},
|
|
103
|
+
): Promise<StartResult> {
|
|
104
|
+
const resolved = resolveMicrosoftClientId(deps.configClientId)
|
|
105
|
+
|
|
106
|
+
// A vaulted BYO client_id can't be resolved from the gateway process
|
|
107
|
+
// (the gateway has no passphrase / vault-broker read path here). The
|
|
108
|
+
// shipped default and literal config values are fine.
|
|
109
|
+
if (isVaultReference(resolved.clientId)) {
|
|
110
|
+
return { kind: 'byo-vault', ref: resolved.clientId }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const scopes = selectMicrosoftScopes(deps.orgMode ?? false)
|
|
114
|
+
const cfg: MicrosoftOAuthClientConfig = {
|
|
115
|
+
client_id: resolved.clientId,
|
|
116
|
+
scopes,
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const device = await (deps.requestDeviceCode ?? realRequestDeviceCode)(cfg)
|
|
120
|
+
return {
|
|
121
|
+
kind: 'started',
|
|
122
|
+
device,
|
|
123
|
+
clientId: resolved.clientId,
|
|
124
|
+
scopes,
|
|
125
|
+
source: resolved.source,
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return { kind: 'error', message: (err as Error).message }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type PollResult =
|
|
133
|
+
| {
|
|
134
|
+
kind: 'connected'
|
|
135
|
+
account: string
|
|
136
|
+
accountType: 'personal' | 'work'
|
|
137
|
+
expiresAt: number
|
|
138
|
+
}
|
|
139
|
+
| { kind: 'cancelled' }
|
|
140
|
+
| { kind: 'no-refresh-token' }
|
|
141
|
+
| { kind: 'failed'; message: string }
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Poll Microsoft for consent completion, then register the account with
|
|
145
|
+
* the broker. Blocks (with the device-code `interval`) up to the
|
|
146
|
+
* device's `expires_in`. Returns a discriminated result the gateway
|
|
147
|
+
* turns into a card edit. Reads `flow.cancelled` after the (potentially
|
|
148
|
+
* long) poll so a `/connect cancel` between consent and write is
|
|
149
|
+
* honored.
|
|
150
|
+
*/
|
|
151
|
+
export async function runMicrosoftConnectPoll(
|
|
152
|
+
flow: Pick<
|
|
153
|
+
PendingMicrosoftConnectFlow,
|
|
154
|
+
'device' | 'clientId' | 'scopes' | 'cancelled'
|
|
155
|
+
>,
|
|
156
|
+
deps: MicrosoftConnectDeps = {},
|
|
157
|
+
): Promise<PollResult> {
|
|
158
|
+
const now = deps.now ?? Date.now
|
|
159
|
+
const cfg: MicrosoftOAuthClientConfig = {
|
|
160
|
+
client_id: flow.clientId,
|
|
161
|
+
scopes: flow.scopes,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let tokens
|
|
165
|
+
try {
|
|
166
|
+
tokens = await (deps.pollDeviceToken ?? realPollDeviceToken)(
|
|
167
|
+
cfg,
|
|
168
|
+
flow.device,
|
|
169
|
+
{ now },
|
|
170
|
+
)
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return { kind: 'failed', message: (err as Error).message }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (flow.cancelled) return { kind: 'cancelled' }
|
|
176
|
+
|
|
177
|
+
const built = buildMicrosoftCredentials({
|
|
178
|
+
tokens,
|
|
179
|
+
clientId: flow.clientId,
|
|
180
|
+
accountEmail: '', // device-code learns the email from the id_token
|
|
181
|
+
fallbackScope: flow.scopes.join(' '),
|
|
182
|
+
now,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// offline_access is requested, so a refresh token is expected; without
|
|
186
|
+
// one the account dies at the first access-token expiry — fail loud
|
|
187
|
+
// rather than register an un-refreshable account.
|
|
188
|
+
if (!built.credentials.microsoftOauth.refreshToken) {
|
|
189
|
+
return { kind: 'no-refresh-token' }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const account = built.resolvedEmail
|
|
193
|
+
if (!account) {
|
|
194
|
+
return {
|
|
195
|
+
kind: 'failed',
|
|
196
|
+
message: 'Microsoft did not return an account identity (no id_token).',
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const addAccount = deps.addAccount ?? defaultAddAccount
|
|
201
|
+
try {
|
|
202
|
+
await addAccount(account, built.credentials as MicrosoftAddAccountCredentials, {
|
|
203
|
+
provider: 'microsoft',
|
|
204
|
+
// replace:true so reconnecting an already-linked account just
|
|
205
|
+
// refreshes its tokens rather than erroring.
|
|
206
|
+
replace: true,
|
|
207
|
+
})
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return { kind: 'failed', message: (err as Error).message }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
kind: 'connected',
|
|
214
|
+
account,
|
|
215
|
+
accountType: built.credentials.microsoftOauth.accountType,
|
|
216
|
+
expiresAt: built.credentials.microsoftOauth.expiresAt,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function defaultAddAccount(
|
|
221
|
+
label: string,
|
|
222
|
+
credentials: MicrosoftAddAccountCredentials,
|
|
223
|
+
opts: { replace?: boolean; provider: 'microsoft' },
|
|
224
|
+
): Promise<{ label: string; expiresAt?: number }> {
|
|
225
|
+
return addAccountViaBroker(label, credentials, opts)
|
|
226
|
+
}
|