switchroom 0.8.1 → 0.10.0

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.
Files changed (105) hide show
  1. package/README.md +49 -57
  2. package/bin/timezone-hook.sh +9 -7
  3. package/dist/agent-scheduler/index.js +285 -45
  4. package/dist/auth-broker/index.js +13932 -0
  5. package/dist/cli/switchroom.js +15931 -12778
  6. package/dist/host-control/main.js +582 -43
  7. package/dist/vault/approvals/kernel-server.js +276 -47
  8. package/dist/vault/broker/server.js +333 -69
  9. package/examples/minimal.yaml +63 -0
  10. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  11. package/examples/personal-google-workspace-mcp/README.md +194 -0
  12. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  13. package/examples/switchroom.yaml +220 -0
  14. package/package.json +6 -4
  15. package/profiles/_base/start.sh.hbs +3 -3
  16. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  17. package/profiles/default/CLAUDE.md +10 -0
  18. package/profiles/default/CLAUDE.md.hbs +16 -0
  19. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  20. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  21. package/skills/buildkite-api/SKILL.md +31 -8
  22. package/skills/buildkite-cli/SKILL.md +27 -9
  23. package/skills/buildkite-migration/SKILL.md +22 -9
  24. package/skills/buildkite-pipelines/SKILL.md +26 -9
  25. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  26. package/skills/buildkite-test-engine/SKILL.md +25 -8
  27. package/skills/docx/SKILL.md +1 -1
  28. package/skills/file-bug/SKILL.md +34 -6
  29. package/skills/humanizer/SKILL.md +15 -0
  30. package/skills/humanizer-calibrate/SKILL.md +7 -1
  31. package/skills/mcp-builder/SKILL.md +1 -1
  32. package/skills/pdf/SKILL.md +1 -1
  33. package/skills/pptx/SKILL.md +1 -1
  34. package/skills/skill-creator/SKILL.md +21 -1
  35. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  36. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  41. package/skills/switchroom-cli/SKILL.md +63 -64
  42. package/skills/switchroom-health/SKILL.md +23 -10
  43. package/skills/switchroom-install/SKILL.md +3 -3
  44. package/skills/switchroom-manage/SKILL.md +26 -19
  45. package/skills/switchroom-runtime/SKILL.md +67 -15
  46. package/skills/switchroom-status/SKILL.md +26 -1
  47. package/skills/telegram-test-harness/SKILL.md +3 -0
  48. package/skills/webapp-testing/SKILL.md +31 -1
  49. package/skills/xlsx/SKILL.md +1 -1
  50. package/telegram-plugin/admin-commands/index.ts +7 -5
  51. package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
  52. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  53. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  54. package/telegram-plugin/gateway/auth-command.ts +794 -0
  55. package/telegram-plugin/gateway/auth-line.ts +123 -0
  56. package/telegram-plugin/gateway/boot-card.ts +22 -36
  57. package/telegram-plugin/gateway/boot-probes.ts +3 -3
  58. package/telegram-plugin/gateway/gateway.ts +313 -798
  59. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  60. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  61. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  62. package/telegram-plugin/permission-title.ts +56 -0
  63. package/telegram-plugin/quota-check.ts +19 -41
  64. package/telegram-plugin/scripts/build.mjs +0 -1
  65. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  66. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  67. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  68. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  69. package/telegram-plugin/tests/boot-probes.test.ts +11 -4
  70. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  71. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  72. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  73. package/telegram-plugin/uat/SETUP.md +31 -1
  74. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  75. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  76. package/telegram-plugin/uat/runners/report.ts +150 -0
  77. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  78. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  79. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  80. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  81. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  82. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  84. package/telegram-plugin/auth-dashboard.ts +0 -1104
  85. package/telegram-plugin/auth-slot-parser.ts +0 -497
  86. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  87. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  88. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  89. package/telegram-plugin/foreman/foreman.ts +0 -1165
  90. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  91. package/telegram-plugin/foreman/setup-state.ts +0 -239
  92. package/telegram-plugin/foreman/state.ts +0 -203
  93. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  94. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  95. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  96. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  97. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  98. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  99. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  100. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  101. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  102. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  103. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  104. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  105. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -0,0 +1,794 @@
1
+ /**
2
+ * `/auth` chat-command parser + handler (RFC H §7.3).
3
+ *
4
+ * The slot-pool-era dashboard at `telegram-plugin/auth-dashboard.ts`
5
+ * — and its callback-driven inline-keyboard surface — is gone. The
6
+ * fleet-wide active-account model offers a verb tree that mirrors
7
+ * `switchroom auth` on the CLI (RFC H Decision 11 — "same shape on
8
+ * the CLI and in Telegram"):
9
+ *
10
+ * /auth — alias of `show`
11
+ * /auth show [<agent>] — fleet snapshot, or one agent's
12
+ * effective account + mirror state
13
+ * /auth list — alias of `show` (no agent)
14
+ * /auth use <label> — admin-only fleet swap
15
+ * /auth rotate — admin-only failover to next non-
16
+ * exhausted entry in fallback_order
17
+ * /auth add <label> — admin-only chat-native OAuth flow
18
+ * /auth cancel — admin-only abort of `/auth add`
19
+ * /auth rm <label> [confirm] — admin-only, two-step destructive
20
+ * /auth refresh [<label>] — admin-only diagnostic force-tick
21
+ * /auth agent override <a> <l|clear>
22
+ * — admin-only per-agent pin
23
+ * /auth help — verb listing
24
+ *
25
+ * Parse is pure (no I/O) so callers can route on the verb without
26
+ * needing a broker. The handler is async and talks to the broker
27
+ * client; on broker failure it returns a user-facing error reply
28
+ * rather than throwing.
29
+ */
30
+
31
+ import type { ListStateData, AccountState } from './auth-line.js'
32
+
33
+ // ─── Parser ────────────────────────────────────────────────────────────────
34
+
35
+ export type ParsedAuthCommand =
36
+ | { kind: 'show'; agent?: string }
37
+ | { kind: 'list' }
38
+ | { kind: 'use'; label: string }
39
+ | { kind: 'rotate' }
40
+ | { kind: 'add'; label: string }
41
+ | { kind: 'cancel' }
42
+ | { kind: 'rm-prompt'; label: string }
43
+ | { kind: 'rm-confirmed'; label: string }
44
+ | { kind: 'refresh'; label?: string }
45
+ | { kind: 'override-set'; agent: string; label: string }
46
+ | { kind: 'override-clear'; agent: string }
47
+ | { kind: 'help'; reason?: string }
48
+
49
+ /**
50
+ * TTL for the two-step `/auth rm` confirm window. Operators have
51
+ * 60s between the prompt and the `confirm` follow-up — long enough
52
+ * to read the warning and switch focus from chat to broker docs,
53
+ * short enough that a stale tab from yesterday can't auto-delete an
54
+ * account by accident.
55
+ */
56
+ export const AUTH_RM_CONFIRM_TTL_MS = 60_000
57
+
58
+ /**
59
+ * In-flight `/auth rm` confirm flow keyed by Telegram chat id.
60
+ * Sibling to `pendingAuthAddFlows` in `auth-add-flow.ts` — same
61
+ * shape, smaller surface (no subprocess to lifecycle-manage).
62
+ * The gateway's chat-command handler reads/writes this map; the
63
+ * confirm verb refuses if the entry is missing, expired, or for a
64
+ * different label.
65
+ */
66
+ export interface PendingAuthRmFlow {
67
+ label: string
68
+ expiresAt: number
69
+ }
70
+ export const pendingAuthRmFlows = new Map<string, PendingAuthRmFlow>()
71
+
72
+ /**
73
+ * Account-label regex — must match the broker's `LABEL_RE` in
74
+ * `src/auth/account-store.ts`. Duplicated rather than imported to
75
+ * keep `auth-command.ts` pure (no side-effecting imports) so the
76
+ * parser is cheap to unit test.
77
+ */
78
+ const LABEL_RE = /^[A-Za-z0-9._@+-]+$/
79
+ const LABEL_MAX = 64
80
+
81
+ /** Returns null when label is valid; otherwise a user-facing error string. */
82
+ export function validateAuthAddLabel(label: string): string | null {
83
+ if (!label || label.length === 0) return 'Label cannot be empty.'
84
+ if (label.length > LABEL_MAX) {
85
+ return `Label too long (max ${LABEL_MAX} chars).`
86
+ }
87
+ if (label === '.' || label === '..') return `Label "${label}" is reserved.`
88
+ if (label.includes('/') || label.includes('\\')) {
89
+ return 'Label cannot contain path separators.'
90
+ }
91
+ if (!LABEL_RE.test(label)) {
92
+ return 'Label must match <code>[A-Za-z0-9._@+-]+</code> (letters, digits, dot, underscore, dash, @, +).'
93
+ }
94
+ return null
95
+ }
96
+
97
+ /**
98
+ * Parse a `/auth …` chat command. Returns `null` when the text is
99
+ * not an `/auth` command at all (so the gateway falls through to its
100
+ * other handlers).
101
+ *
102
+ * Whitespace tolerant; case-insensitive on the verb. `/auth` alone
103
+ * resolves to `show` (the read-only default).
104
+ */
105
+ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
106
+ const trimmed = text.trim()
107
+ if (trimmed.length === 0) return null
108
+ // Allow `/auth`, `/auth@botname`, `/auth foo` — the leading token
109
+ // must be `/auth` (optionally with a bot-suffix) or the message
110
+ // isn't ours.
111
+ const m = trimmed.match(/^\/auth(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$/)
112
+ if (!m) return null
113
+ const rest = (m[1] ?? '').trim()
114
+ if (rest.length === 0) return { kind: 'show' }
115
+ const parts = rest.split(/\s+/)
116
+ const verb = (parts[0] ?? '').toLowerCase()
117
+ switch (verb) {
118
+ case 'show': {
119
+ const agent = parts[1]
120
+ if (agent) return { kind: 'show', agent }
121
+ return { kind: 'show' }
122
+ }
123
+ case 'list':
124
+ // List is a strict alias of bare `/auth show` (fleet snapshot).
125
+ // Per RFC H Decision 11 — same shape as the CLI verb.
126
+ return { kind: 'list' }
127
+ case 'rotate':
128
+ return { kind: 'rotate' }
129
+ case 'use': {
130
+ const label = parts[1]
131
+ if (!label) return { kind: 'help', reason: 'Usage: /auth use <label>' }
132
+ return { kind: 'use', label }
133
+ }
134
+ case 'add': {
135
+ const label = parts[1]
136
+ if (!label) return { kind: 'help', reason: 'Usage: /auth add <label>' }
137
+ const err = validateAuthAddLabel(label)
138
+ if (err) return { kind: 'help', reason: err }
139
+ return { kind: 'add', label }
140
+ }
141
+ case 'cancel':
142
+ return { kind: 'cancel' }
143
+ case 'rm': {
144
+ const label = parts[1]
145
+ if (!label) return { kind: 'help', reason: 'Usage: /auth rm <label> [confirm]' }
146
+ const labelErr = validateAuthAddLabel(label)
147
+ if (labelErr) return { kind: 'help', reason: labelErr }
148
+ // Two-step: a literal `confirm` token in slot 2 means "phase 2 —
149
+ // actually delete". Anything else is a "phase 1 — show prompt".
150
+ const tail = (parts[2] ?? '').toLowerCase()
151
+ if (tail === 'confirm') return { kind: 'rm-confirmed', label }
152
+ if (tail.length > 0) {
153
+ return {
154
+ kind: 'help',
155
+ reason: `Unknown <code>rm</code> modifier: <code>${escapeHtml(tail)}</code>. Use <code>/auth rm &lt;label&gt; confirm</code> to confirm.`,
156
+ }
157
+ }
158
+ return { kind: 'rm-prompt', label }
159
+ }
160
+ case 'refresh': {
161
+ const label = parts[1]
162
+ if (label) {
163
+ const err = validateAuthAddLabel(label)
164
+ if (err) return { kind: 'help', reason: err }
165
+ return { kind: 'refresh', label }
166
+ }
167
+ return { kind: 'refresh' }
168
+ }
169
+ case 'agent': {
170
+ // Only `/auth agent override <agent> <label|clear>` is wired. Any
171
+ // other shape is a help-with-reason so the operator sees the
172
+ // expected verb tree.
173
+ const sub = (parts[1] ?? '').toLowerCase()
174
+ if (sub !== 'override') {
175
+ return {
176
+ kind: 'help',
177
+ reason: `Unknown <code>agent</code> subcommand: <code>${escapeHtml(sub || '(none)')}</code>. Try <code>/auth agent override &lt;agent&gt; &lt;label|clear&gt;</code>.`,
178
+ }
179
+ }
180
+ const agent = parts[2]
181
+ const target = parts[3]
182
+ if (!agent || !target) {
183
+ return {
184
+ kind: 'help',
185
+ reason: 'Usage: /auth agent override &lt;agent&gt; &lt;label|clear&gt;',
186
+ }
187
+ }
188
+ if (target.toLowerCase() === 'clear') {
189
+ return { kind: 'override-clear', agent }
190
+ }
191
+ const labelErr = validateAuthAddLabel(target)
192
+ if (labelErr) return { kind: 'help', reason: labelErr }
193
+ return { kind: 'override-set', agent, label: target }
194
+ }
195
+ case 'help':
196
+ return { kind: 'help' }
197
+ default:
198
+ return { kind: 'help', reason: `Unknown verb: <code>${escapeHtml(verb)}</code>` }
199
+ }
200
+ }
201
+
202
+ // ─── Handler ───────────────────────────────────────────────────────────────
203
+
204
+ /**
205
+ * Broker client surface this handler depends on. Kept narrow so the
206
+ * gateway can inject the real client (`src/auth/broker/client.ts`)
207
+ * and tests can pass a mock without juggling the full NDJSON shape.
208
+ */
209
+ export interface AuthBrokerClient {
210
+ listState(): Promise<ListStateData>
211
+ setActive(label: string): Promise<{ active: string; fanned: string[] }>
212
+ rmAccount(label: string): Promise<{ label: string }>
213
+ refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
214
+ setOverride(
215
+ agent: string,
216
+ account: string | null,
217
+ ): Promise<{ agent: string; account: string | null }>
218
+ }
219
+
220
+ export interface AuthCommandContext {
221
+ /** The agent the gateway is bound to (its socket-path identity). */
222
+ agentName: string
223
+ /**
224
+ * True when this agent is an admin (per `agents.<name>.admin: true`
225
+ * in switchroom.yaml — the same flag PR #1258 introduced for fleet
226
+ * ops, unified into the auth-broker gate by PR #1263). Computed by
227
+ * the gateway from its loaded config and passed through; the
228
+ * handler does not consult any list.
229
+ */
230
+ isAdmin: boolean
231
+ client: AuthBrokerClient
232
+ /**
233
+ * Telegram chat id this command was issued in. Used to key the
234
+ * `/auth rm` two-step confirm window (see `pendingAuthRmFlows`).
235
+ * Optional only so legacy gateway-routed verbs (`add`, `cancel`)
236
+ * that never reach the destructive branches can skip wiring it.
237
+ */
238
+ chatId?: string
239
+ }
240
+
241
+ export interface AuthCommandReply {
242
+ text: string
243
+ /** True when the reply contains HTML markup. */
244
+ html: boolean
245
+ }
246
+
247
+ /**
248
+ * Dispatch a parsed `/auth` command. Returns the reply the gateway
249
+ * should send. Never throws — broker errors surface as user-visible
250
+ * text.
251
+ */
252
+ export async function handleAuthCommand(
253
+ parsed: ParsedAuthCommand,
254
+ ctx: AuthCommandContext,
255
+ ): Promise<AuthCommandReply> {
256
+ if (parsed.kind === 'help') {
257
+ const reason = parsed.reason ? `${parsed.reason}\n\n` : ''
258
+ return {
259
+ text:
260
+ `${reason}<b>/auth</b> — verbs (mirror of <code>switchroom auth</code>):\n` +
261
+ ` <code>/auth</code> — show fleet snapshot (alias of <code>show</code>)\n` +
262
+ ` <code>/auth show</code> — show fleet snapshot\n` +
263
+ ` <code>/auth show &lt;agent&gt;</code> — show one agent's effective account + mirror state\n` +
264
+ ` <code>/auth list</code> — alias of <code>/auth show</code>\n` +
265
+ ` <code>/auth use &lt;label&gt;</code> — admin: swap the fleet to &lt;label&gt;\n` +
266
+ ` <code>/auth rotate</code> — admin: cycle to next non-exhausted fallback\n` +
267
+ ` <code>/auth add &lt;label&gt;</code> — admin: OAuth-add a new account from chat\n` +
268
+ ` <code>/auth cancel</code> — abort an <code>/auth add</code> in progress\n` +
269
+ ` <code>/auth rm &lt;label&gt;</code> — admin: remove an account (two-step confirm)\n` +
270
+ ` <code>/auth refresh [&lt;label&gt;]</code> — admin: force a refresh tick\n` +
271
+ ` <code>/auth agent override &lt;agent&gt; &lt;label|clear&gt;</code> — admin: per-agent account override\n` +
272
+ ` <code>/auth help</code> — this list`,
273
+ html: true,
274
+ }
275
+ }
276
+
277
+ // `show` (no agent) and `list` both render the fleet snapshot; share
278
+ // one code path so the two verbs can't diverge.
279
+ if (
280
+ parsed.kind === 'list' ||
281
+ (parsed.kind === 'show' && parsed.agent === undefined)
282
+ ) {
283
+ try {
284
+ const state = await ctx.client.listState()
285
+ return { text: renderShowText(state), html: true }
286
+ } catch (err) {
287
+ return {
288
+ text: `<b>/auth show failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
289
+ html: true,
290
+ }
291
+ }
292
+ }
293
+
294
+ if (parsed.kind === 'show') {
295
+ // parsed.agent is non-undefined by the branch above.
296
+ const agentName = parsed.agent as string
297
+ try {
298
+ const state = await ctx.client.listState()
299
+ const agent = state.agents.find((a) => a.name === agentName)
300
+ if (!agent) {
301
+ return {
302
+ text:
303
+ `<b>/auth show:</b> no agent named <code>${escapeHtml(agentName)}</code> in broker view.\n` +
304
+ `Run <code>/auth show</code> for the fleet snapshot.`,
305
+ html: true,
306
+ }
307
+ }
308
+ return { text: renderAgentDetail(state, agent), html: true }
309
+ } catch (err) {
310
+ return {
311
+ text: `<b>/auth show failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
312
+ html: true,
313
+ }
314
+ }
315
+ }
316
+
317
+ // Admin-gated verbs from here on.
318
+ if (!isAdmin(ctx)) {
319
+ return {
320
+ text:
321
+ `<b>Not authorized.</b> <code>/auth ${parsed.kind}</code> is admin-only.\n` +
322
+ `Set <code>admin: true</code> on this agent in switchroom.yaml to unlock ` +
323
+ `(the same flag that gates <code>/agents</code>, <code>/restart</code>, ` +
324
+ `<code>/update</code> etc.).`,
325
+ html: true,
326
+ }
327
+ }
328
+
329
+ // `add` and `cancel` are dispatched directly by the gateway (they
330
+ // need to drive the `claude setup-token` scratch-dir lifecycle and
331
+ // the per-chat pending-paste state). They should never reach this
332
+ // handler in production — if they do (defensive), return a clear
333
+ // error rather than silently coercing into a different verb.
334
+ if (parsed.kind === 'add' || parsed.kind === 'cancel') {
335
+ return {
336
+ text:
337
+ `<b>/auth ${parsed.kind} not routed.</b> Internal error — gateway should dispatch this verb directly. Report this.`,
338
+ html: true,
339
+ }
340
+ }
341
+
342
+ if (parsed.kind === 'use') {
343
+ try {
344
+ const result = await ctx.client.setActive(parsed.label)
345
+ return {
346
+ text:
347
+ `<b>Active account →</b> <code>${escapeHtml(result.active)}</code>\n` +
348
+ `Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}.`,
349
+ html: true,
350
+ }
351
+ } catch (err) {
352
+ return {
353
+ text: `<b>/auth use failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
354
+ html: true,
355
+ }
356
+ }
357
+ }
358
+
359
+ if (parsed.kind === 'rotate') {
360
+ try {
361
+ const state = await ctx.client.listState()
362
+ const nextLabel = pickRotateTarget(state)
363
+ if (!nextLabel) {
364
+ return {
365
+ text:
366
+ `<b>/auth rotate</b> — no eligible target.\n` +
367
+ `Either every account in <code>fallback_order</code> is exhausted, ` +
368
+ `or no fallback order is configured.`,
369
+ html: true,
370
+ }
371
+ }
372
+ const result = await ctx.client.setActive(nextLabel)
373
+ return {
374
+ text:
375
+ `<b>Rotated:</b> active → <code>${escapeHtml(result.active)}</code>\n` +
376
+ `Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}.`,
377
+ html: true,
378
+ }
379
+ } catch (err) {
380
+ return {
381
+ text: `<b>/auth rotate failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
382
+ html: true,
383
+ }
384
+ }
385
+ }
386
+
387
+ if (parsed.kind === 'rm-prompt') {
388
+ // Phase 1 — gate, validate against current state, stash pending.
389
+ // Refuse early if the label is unknown or is the fleet active, so
390
+ // the destructive prompt itself can't lie about what's possible.
391
+ let state: ListStateData
392
+ try {
393
+ state = await ctx.client.listState()
394
+ } catch (err) {
395
+ return {
396
+ text: `<b>/auth rm failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
397
+ html: true,
398
+ }
399
+ }
400
+ const exists = state.accounts.some((a) => a.label === parsed.label)
401
+ if (!exists) {
402
+ return {
403
+ text:
404
+ `<b>/auth rm:</b> no account named <code>${escapeHtml(parsed.label)}</code>. ` +
405
+ `Run <code>/auth show</code> for the current list.`,
406
+ html: true,
407
+ }
408
+ }
409
+ if (state.active === parsed.label) {
410
+ return {
411
+ text:
412
+ `<b>/auth rm refused.</b> <code>${escapeHtml(parsed.label)}</code> is the fleet active. ` +
413
+ `Switch with <code>/auth use &lt;other&gt;</code> or <code>/auth rotate</code> first.`,
414
+ html: true,
415
+ }
416
+ }
417
+ // Stash. The gateway is responsible for keying this map by chat
418
+ // id; the handler can't see ctx.chat. We expose the helper as a
419
+ // mutation through the side-channel below so the gateway can wire
420
+ // it after admin-gating. To keep the handler self-contained for
421
+ // tests, fall back to populating the map directly when a chatId
422
+ // is supplied via ctx (set by the gateway wrapper).
423
+ if (ctx.chatId) {
424
+ pendingAuthRmFlows.set(ctx.chatId, {
425
+ label: parsed.label,
426
+ expiresAt: Date.now() + AUTH_RM_CONFIRM_TTL_MS,
427
+ })
428
+ }
429
+ return {
430
+ text:
431
+ `<b>⚠ /auth rm</b> — about to remove <code>${escapeHtml(parsed.label)}</code> from the broker.\n` +
432
+ `The fleet active is unchanged. Any agent override pointing at <code>${escapeHtml(parsed.label)}</code> will stop working.\n\n` +
433
+ `Send <code>/auth rm ${escapeHtml(parsed.label)} confirm</code> within ${Math.round(
434
+ AUTH_RM_CONFIRM_TTL_MS / 1000,
435
+ )}s to proceed.`,
436
+ html: true,
437
+ }
438
+ }
439
+
440
+ if (parsed.kind === 'rm-confirmed') {
441
+ const pending = ctx.chatId ? pendingAuthRmFlows.get(ctx.chatId) : undefined
442
+ const now = Date.now()
443
+ if (!pending || pending.label !== parsed.label || pending.expiresAt <= now) {
444
+ if (ctx.chatId && pending && pending.expiresAt <= now) {
445
+ pendingAuthRmFlows.delete(ctx.chatId)
446
+ }
447
+ return {
448
+ text:
449
+ `<b>/auth rm:</b> no pending confirm for <code>${escapeHtml(parsed.label)}</code> (expired or not started). ` +
450
+ `Send <code>/auth rm ${escapeHtml(parsed.label)}</code> first.`,
451
+ html: true,
452
+ }
453
+ }
454
+ // Clear before the broker call — re-entrance / double-tap should
455
+ // not delete twice.
456
+ if (ctx.chatId) pendingAuthRmFlows.delete(ctx.chatId)
457
+ try {
458
+ const data = await ctx.client.rmAccount(parsed.label)
459
+ return {
460
+ text: `<b>Removed</b> <code>${escapeHtml(data.label)}</code> from the broker.`,
461
+ html: true,
462
+ }
463
+ } catch (err) {
464
+ return {
465
+ text: `<b>/auth rm failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
466
+ html: true,
467
+ }
468
+ }
469
+ }
470
+
471
+ if (parsed.kind === 'refresh') {
472
+ try {
473
+ const state = await ctx.client.listState()
474
+ const targets = parsed.label
475
+ ? state.accounts.filter((a) => a.label === parsed.label).map((a) => a.label)
476
+ : state.accounts.map((a) => a.label)
477
+ if (parsed.label && targets.length === 0) {
478
+ return {
479
+ text:
480
+ `<b>/auth refresh:</b> no account named <code>${escapeHtml(parsed.label)}</code>.`,
481
+ html: true,
482
+ }
483
+ }
484
+ if (targets.length === 0) {
485
+ return { text: `<b>/auth refresh:</b> no accounts to refresh.`, html: true }
486
+ }
487
+ const oldByLabel = new Map(state.accounts.map((a) => [a.label, a.expiresAt]))
488
+ const rows: string[][] = [['ACCOUNT', 'OLD EXPIRY', 'NEW EXPIRY']]
489
+ const failures: string[] = []
490
+ for (const label of targets) {
491
+ try {
492
+ const data = await ctx.client.refreshAccount(label)
493
+ rows.push([
494
+ label,
495
+ formatExpiryAbs(oldByLabel.get(label)),
496
+ formatExpiryAbs(data.expiresAt),
497
+ ])
498
+ } catch (err) {
499
+ failures.push(
500
+ `${label}: ${escapeHtml((err as Error)?.message ?? String(err))}`,
501
+ )
502
+ }
503
+ }
504
+ const head =
505
+ targets.length === 1
506
+ ? `<b>Refreshed</b> <code>${escapeHtml(targets[0]!)}</code>`
507
+ : `<b>Refreshed</b> ${rows.length - 1}/${targets.length} account${targets.length === 1 ? '' : 's'}`
508
+ const table = rows.length > 1
509
+ ? `\n<pre>${alignTable(rows)}</pre>`
510
+ : ''
511
+ const failBlock = failures.length > 0
512
+ ? `\n<b>Failures:</b>\n${failures.map((f) => ` ${f}`).join('\n')}`
513
+ : ''
514
+ return { text: head + table + failBlock, html: true }
515
+ } catch (err) {
516
+ return {
517
+ text: `<b>/auth refresh failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
518
+ html: true,
519
+ }
520
+ }
521
+ }
522
+
523
+ if (parsed.kind === 'override-set') {
524
+ try {
525
+ const data = await ctx.client.setOverride(parsed.agent, parsed.label)
526
+ return {
527
+ text:
528
+ `<b>Override set.</b> <code>${escapeHtml(data.agent)}</code> is now pinned to ` +
529
+ `<code>${escapeHtml(data.account ?? parsed.label)}</code>.`,
530
+ html: true,
531
+ }
532
+ } catch (err) {
533
+ return {
534
+ text: `<b>/auth agent override failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
535
+ html: true,
536
+ }
537
+ }
538
+ }
539
+
540
+ if (parsed.kind === 'override-clear') {
541
+ try {
542
+ const data = await ctx.client.setOverride(parsed.agent, null)
543
+ return {
544
+ text:
545
+ `<b>Override cleared</b> on <code>${escapeHtml(data.agent)}</code> ` +
546
+ `— back to fleet active.`,
547
+ html: true,
548
+ }
549
+ } catch (err) {
550
+ return {
551
+ text: `<b>/auth agent override failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
552
+ html: true,
553
+ }
554
+ }
555
+ }
556
+
557
+ // Exhaustiveness — any future ParsedAuthCommand variant lands here.
558
+ const _exhaustive: never = parsed
559
+ void _exhaustive
560
+ return {
561
+ text: `<b>/auth:</b> unhandled verb. Report this.`,
562
+ html: true,
563
+ }
564
+ }
565
+
566
+ // ─── Helpers ───────────────────────────────────────────────────────────────
567
+
568
+ /**
569
+ * Admin gate. Exposed so the gateway-routed verbs (`/auth add`,
570
+ * `/auth cancel`) reuse the same ACL check as the handler-routed
571
+ * verbs (`/auth use`, `/auth rotate`).
572
+ *
573
+ * Post-RFC-H + PR #1263 unification, admin is a per-agent boolean
574
+ * (`agents.<name>.admin === true`) computed by the gateway from
575
+ * its loaded config. The gate is the boolean lookup itself.
576
+ */
577
+ export function isAuthAdmin(args: { isAdmin: boolean }): boolean {
578
+ return args.isAdmin === true
579
+ }
580
+
581
+ function isAdmin(ctx: AuthCommandContext): boolean {
582
+ return ctx.isAdmin === true
583
+ }
584
+
585
+ /**
586
+ * Choose the next account `auth rotate` should set active. Walks
587
+ * `fallback_order` starting *after* the currently-active label,
588
+ * wrapping; returns the first label whose account is not exhausted.
589
+ * Returns null when nothing is eligible.
590
+ */
591
+ export function pickRotateTarget(state: ListStateData, now: number = Date.now()): string | null {
592
+ const order = state.fallback_order
593
+ if (order.length === 0) return null
594
+ const byLabel = new Map<string, AccountState>(state.accounts.map((a) => [a.label, a]))
595
+ const start = Math.max(0, order.indexOf(state.active))
596
+ for (let step = 1; step <= order.length; step++) {
597
+ const candidate = order[(start + step) % order.length]
598
+ if (!candidate || candidate === state.active) continue
599
+ const acc = byLabel.get(candidate)
600
+ if (!acc) continue
601
+ if (acc.exhausted && (acc.exhausted_until == null || acc.exhausted_until > now)) continue
602
+ return candidate
603
+ }
604
+ return null
605
+ }
606
+
607
+ /**
608
+ * Render the two-table `auth show` format from RFC §4.6, adapted for
609
+ * Telegram (HTML, monospace blocks). Three sections, each suppressed
610
+ * when empty.
611
+ */
612
+ export function renderShowText(state: ListStateData, now: number = Date.now()): string {
613
+ const lines: string[] = []
614
+ lines.push('<b>Auth — fleet snapshot</b>')
615
+
616
+ // Accounts table
617
+ if (state.accounts.length > 0) {
618
+ lines.push('')
619
+ lines.push('<b>Accounts</b>')
620
+ lines.push('<pre>')
621
+ lines.push(formatAccountsTable(state, now))
622
+ lines.push('</pre>')
623
+ }
624
+
625
+ // Agents table
626
+ if (state.agents.length > 0) {
627
+ lines.push('<b>Agents</b>')
628
+ lines.push('<pre>')
629
+ lines.push(formatAgentsTable(state))
630
+ lines.push('</pre>')
631
+ }
632
+
633
+ // Consumers table — only when there are any (typical case: hindsight).
634
+ if (state.consumers.length > 0) {
635
+ lines.push('<b>Consumers</b>')
636
+ lines.push('<pre>')
637
+ lines.push(formatConsumersTable(state, now))
638
+ lines.push('</pre>')
639
+ }
640
+
641
+ // Discovery hint — operators on a quota-walled fleet need to know
642
+ // `/auth add` exists so they can add a fresh account without an
643
+ // LLM in the loop. Keep it short; the help text has the full menu.
644
+ lines.push(
645
+ '<i>Add a new Anthropic account: <code>/auth add &lt;label&gt;</code> (admin)</i>',
646
+ )
647
+
648
+ return lines.join('\n')
649
+ }
650
+
651
+ function formatAccountsTable(state: ListStateData, now: number): string {
652
+ const rows: string[][] = [['ACCOUNT', 'STATUS', 'EXPIRES', 'QUOTA-RESET']]
653
+ for (const acc of state.accounts) {
654
+ const isActive = acc.label === state.active
655
+ const marker = isActive
656
+ ? '●' // ●
657
+ : acc.exhausted
658
+ ? '!'
659
+ : '✓' // ✓
660
+ const status = isActive ? 'active' : acc.exhausted ? 'exhausted' : 'available'
661
+ const expires = acc.expiresAt != null ? formatRelativeMs(acc.expiresAt - now) : '—'
662
+ const quotaReset =
663
+ acc.exhausted && acc.exhausted_until != null && acc.exhausted_until > now
664
+ ? formatRelativeMs(acc.exhausted_until - now)
665
+ : '—'
666
+ rows.push([`${marker} ${escapeHtml(acc.label)}`, status, expires, quotaReset])
667
+ }
668
+ return alignTable(rows)
669
+ }
670
+
671
+ function formatAgentsTable(state: ListStateData): string {
672
+ const rows: string[][] = [['AGENT', 'ACTIVE', 'SOURCE']]
673
+ for (const a of state.agents) {
674
+ const source = a.override
675
+ ? 'override'
676
+ : a.account === state.active
677
+ ? 'fleet-active'
678
+ : 'pinned'
679
+ rows.push([escapeHtml(a.name), escapeHtml(a.account), source])
680
+ }
681
+ return alignTable(rows)
682
+ }
683
+
684
+ /**
685
+ * Per-agent detail block — what `switchroom auth show <agent>` prints
686
+ * on the CLI, adapted to Telegram HTML. Shows effective account,
687
+ * override-vs-fleet-active source, token expiry, last refresh, and
688
+ * exhausted / threshold-violation warnings when relevant.
689
+ */
690
+ export function renderAgentDetail(
691
+ state: ListStateData,
692
+ agent: { name: string; account: string; override: string | null },
693
+ now: number = Date.now(),
694
+ ): string {
695
+ const lines: string[] = []
696
+ lines.push(`<b>${escapeHtml(agent.name)}</b>`)
697
+ const source = agent.override ? 'override' : 'fleet-active'
698
+ lines.push(
699
+ `Active account: <code>${escapeHtml(agent.account)}</code> (${source})`,
700
+ )
701
+ const acct = state.accounts.find((a) => a.label === agent.account)
702
+ if (acct) {
703
+ const expRel = acct.expiresAt != null ? formatRelativeMs(acct.expiresAt - now) : '—'
704
+ lines.push(`Token expires: ${expRel}`)
705
+ if (typeof acct.last_refreshed_at === 'number') {
706
+ lines.push(
707
+ `Last refresh: ${formatRelativeMs(now - acct.last_refreshed_at)} ago`,
708
+ )
709
+ }
710
+ if (acct.exhausted) {
711
+ const resetRel =
712
+ acct.exhausted_until != null && acct.exhausted_until > now
713
+ ? formatRelativeMs(acct.exhausted_until - now)
714
+ : '—'
715
+ lines.push(`<i>Quota: exhausted · resets in ${resetRel}</i>`)
716
+ }
717
+ if (typeof acct.threshold_violations === 'number' && acct.threshold_violations > 0) {
718
+ lines.push(
719
+ `<i>Threshold violations: ${acct.threshold_violations} — claude refreshed under the broker's feet</i>`,
720
+ )
721
+ }
722
+ }
723
+ return lines.join('\n')
724
+ }
725
+
726
+ function formatConsumersTable(state: ListStateData, now: number): string {
727
+ const rows: string[][] = [['CONSUMER', 'ACTIVE', 'STATUS']]
728
+ for (const c of state.consumers) {
729
+ const status =
730
+ c.last_seen_at == null
731
+ ? 'socket bound'
732
+ : `socket bound (last seen ${formatRelativeMs(now - c.last_seen_at)} ago)`
733
+ rows.push([escapeHtml(c.name), escapeHtml(c.account), status])
734
+ }
735
+ return alignTable(rows)
736
+ }
737
+
738
+ // ─── Plain-text helpers ────────────────────────────────────────────────────
739
+
740
+ function escapeHtml(s: string): string {
741
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
742
+ }
743
+
744
+ function formatRelativeMs(ms: number): string {
745
+ if (ms <= 0) return '0s'
746
+ const totalSec = Math.floor(ms / 1000)
747
+ const days = Math.floor(totalSec / 86400)
748
+ const hours = Math.floor((totalSec % 86400) / 3600)
749
+ const mins = Math.floor((totalSec % 3600) / 60)
750
+ const secs = totalSec % 60
751
+ if (days > 0) return `${days}d ${hours}h`
752
+ if (hours > 0) return `${hours}h ${mins}m`
753
+ if (mins > 0) return `${mins}m ${secs}s`
754
+ return `${secs}s`
755
+ }
756
+
757
+ /**
758
+ * Format an absolute expiresAt epoch-ms as a short relative-to-now
759
+ * string. Returns `'—'` when the value is missing or non-finite.
760
+ * Used in the /auth refresh old-vs-new table.
761
+ */
762
+ function formatExpiryAbs(expiresAt?: number, now: number = Date.now()): string {
763
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return '—'
764
+ const delta = expiresAt - now
765
+ if (delta <= 0) return 'expired'
766
+ return formatRelativeMs(delta)
767
+ }
768
+
769
+ /**
770
+ * Right-pad columns so they line up under a fixed-width Telegram
771
+ * `<pre>` block. Last column is left untrimmed so it can run to its
772
+ * natural width.
773
+ */
774
+ function alignTable(rows: string[][]): string {
775
+ if (rows.length === 0) return ''
776
+ const widths: number[] = []
777
+ for (const row of rows) {
778
+ for (let i = 0; i < row.length; i++) {
779
+ const cell = row[i] ?? ''
780
+ widths[i] = Math.max(widths[i] ?? 0, cell.length)
781
+ }
782
+ }
783
+ const out: string[] = []
784
+ for (const row of rows) {
785
+ const parts: string[] = []
786
+ for (let i = 0; i < row.length; i++) {
787
+ const cell = row[i] ?? ''
788
+ if (i === row.length - 1) parts.push(cell)
789
+ else parts.push(cell.padEnd(widths[i] ?? cell.length, ' '))
790
+ }
791
+ out.push(parts.join(' '))
792
+ }
793
+ return out.join('\n')
794
+ }