switchroom 0.7.15 → 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.
- package/README.md +51 -59
- package/bin/run-hook.sh +27 -11
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +410 -133
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +26937 -5601
- package/dist/host-control/main.js +12702 -0
- package/dist/vault/approvals/kernel-server.js +467 -184
- package/dist/vault/broker/server.js +1430 -724
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +7 -4
- package/profiles/_base/settings.json.hbs +20 -5
- package/profiles/_base/start.sh.hbs +16 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/_shared/telegram-style.md.hbs +20 -90
- package/profiles/_shared/vault-protocol.md.hbs +68 -0
- package/profiles/default/CLAUDE.md +50 -96
- package/profiles/default/CLAUDE.md.hbs +36 -6
- package/profiles/default/workspace/SOUL.md.hbs +12 -5
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +191 -0
- package/skills/switchroom-status/SKILL.md +27 -2
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/token-helpers/SKILL.md +24 -1
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/analytics-posthog.ts +191 -0
- package/telegram-plugin/bridge/bridge.ts +69 -0
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/dist/bridge/bridge.js +194 -119
- package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
- package/telegram-plugin/dist/server.js +245 -189
- package/telegram-plugin/first-paint.ts +3 -24
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -40
- package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
- package/telegram-plugin/gateway/boot-probes.ts +166 -123
- package/telegram-plugin/gateway/boot-reason.ts +41 -7
- package/telegram-plugin/gateway/boot-version.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +3499 -1885
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
- package/telegram-plugin/gateway/quarantine.ts +69 -0
- package/telegram-plugin/gateway/quota-cache.ts +9 -4
- package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
- package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
- package/telegram-plugin/gateway/recent-denials.ts +77 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
- package/telegram-plugin/history.ts +91 -0
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/inbound-classifier.ts +50 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +4 -2
- package/telegram-plugin/permission-rule.ts +51 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/registry/reaper.ts +223 -0
- package/telegram-plugin/retry-api-call.ts +80 -0
- package/telegram-plugin/runtime-metrics.ts +177 -0
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/secret-detect/index.ts +24 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
- package/telegram-plugin/secret-detect/vault-error.ts +78 -11
- package/telegram-plugin/secret-detect/vault-write.ts +14 -2
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +420 -0
- package/telegram-plugin/silent-end.ts +174 -0
- package/telegram-plugin/stream-controller.ts +13 -0
- package/telegram-plugin/stream-reply-handler.ts +7 -0
- package/telegram-plugin/subagent-watcher.ts +213 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
- package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
- package/telegram-plugin/tests/boot-probes.test.ts +216 -10
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
- package/telegram-plugin/tests/history-reaper.test.ts +378 -0
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
- package/telegram-plugin/tests/issues-card.test.ts +49 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
- package/telegram-plugin/tests/permission-rule.test.ts +80 -1
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/races.test.ts +179 -0
- package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
- package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
- package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
- package/telegram-plugin/tests/silence-poke.test.ts +493 -0
- package/telegram-plugin/tests/silent-end.test.ts +206 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
- package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
- package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
- package/telegram-plugin/turn-signal-tracker.ts +100 -24
- package/telegram-plugin/uat/SETUP.md +210 -35
- package/telegram-plugin/uat/assertions.ts +264 -37
- package/telegram-plugin/uat/driver-info.ts +57 -0
- package/telegram-plugin/uat/driver.ts +590 -51
- package/telegram-plugin/uat/harness.ts +140 -94
- package/telegram-plugin/uat/load-env.test.ts +72 -0
- package/telegram-plugin/uat/load-env.ts +48 -0
- package/telegram-plugin/uat/login.ts +96 -53
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
- package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
- package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
- package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
- package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
- package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
- package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
- package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
- package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
- package/telegram-plugin/vault-approval-posture.ts +42 -0
- package/telegram-plugin/welcome-text.ts +1 -0
- package/telegram-plugin/active-pins-sweep.ts +0 -204
- package/telegram-plugin/active-pins.ts +0 -146
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/card-event-log.ts +0 -138
- package/telegram-plugin/dist/foreman/foreman.js +0 -31106
- package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/pin-event-log.ts +0 -76
- package/telegram-plugin/progress-card-driver.ts +0 -2886
- package/telegram-plugin/progress-card-pin-manager.ts +0 -589
- package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
- package/telegram-plugin/progress-card.ts +0 -1409
- package/telegram-plugin/tests/HARNESS.md +0 -340
- package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
- package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
- package/telegram-plugin/tests/active-pins.test.ts +0 -187
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/card-event-log.test.ts +0 -145
- package/telegram-plugin/tests/first-paint.test.ts +0 -257
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
- package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
- package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
- package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
- package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
- package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
- package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
- package/telegram-plugin/two-zone-card.ts +0 -269
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
|
@@ -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 <label> 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 <agent> <label|clear></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 <agent> <label|clear>',
|
|
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 <agent></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 <label></code> — admin: swap the fleet to <label>\n` +
|
|
266
|
+
` <code>/auth rotate</code> — admin: cycle to next non-exhausted fallback\n` +
|
|
267
|
+
` <code>/auth add <label></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 <label></code> — admin: remove an account (two-step confirm)\n` +
|
|
270
|
+
` <code>/auth refresh [<label>]</code> — admin: force a refresh tick\n` +
|
|
271
|
+
` <code>/auth agent override <agent> <label|clear></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 <other></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 <label></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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|