switchroom 0.8.1 → 0.11.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 (137) hide show
  1. package/README.md +54 -61
  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/drive-write-pretool.mjs +5418 -0
  6. package/dist/cli/switchroom.js +8890 -5560
  7. package/dist/host-control/main.js +582 -43
  8. package/dist/vault/approvals/kernel-server.js +276 -47
  9. package/dist/vault/broker/server.js +333 -69
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +6 -4
  16. package/profiles/_base/start.sh.hbs +3 -3
  17. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  18. package/profiles/default/CLAUDE.md +10 -0
  19. package/profiles/default/CLAUDE.md.hbs +16 -0
  20. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  21. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  22. package/skills/buildkite-api/SKILL.md +31 -8
  23. package/skills/buildkite-cli/SKILL.md +27 -9
  24. package/skills/buildkite-migration/SKILL.md +22 -9
  25. package/skills/buildkite-pipelines/SKILL.md +26 -9
  26. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  27. package/skills/buildkite-test-engine/SKILL.md +25 -8
  28. package/skills/docx/SKILL.md +1 -1
  29. package/skills/file-bug/SKILL.md +34 -6
  30. package/skills/humanizer/SKILL.md +15 -0
  31. package/skills/humanizer-calibrate/SKILL.md +7 -1
  32. package/skills/mcp-builder/SKILL.md +1 -1
  33. package/skills/pdf/SKILL.md +1 -1
  34. package/skills/pptx/SKILL.md +1 -1
  35. package/skills/skill-creator/SKILL.md +21 -1
  36. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  41. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  42. package/skills/switchroom-cli/SKILL.md +63 -64
  43. package/skills/switchroom-health/SKILL.md +23 -10
  44. package/skills/switchroom-install/SKILL.md +3 -3
  45. package/skills/switchroom-manage/SKILL.md +26 -19
  46. package/skills/switchroom-runtime/SKILL.md +67 -15
  47. package/skills/switchroom-status/SKILL.md +26 -1
  48. package/skills/telegram-test-harness/SKILL.md +3 -0
  49. package/skills/webapp-testing/SKILL.md +31 -1
  50. package/skills/xlsx/SKILL.md +1 -1
  51. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  52. package/telegram-plugin/admin-commands/index.ts +9 -5
  53. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  54. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  55. package/telegram-plugin/auto-fallback.ts +28 -301
  56. package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
  57. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  58. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  59. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  60. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  61. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  62. package/telegram-plugin/gateway/auth-command.ts +905 -0
  63. package/telegram-plugin/gateway/auth-line.ts +123 -0
  64. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  65. package/telegram-plugin/gateway/boot-card.ts +23 -37
  66. package/telegram-plugin/gateway/boot-probes.ts +9 -12
  67. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  68. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  69. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  70. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  71. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  72. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  73. package/telegram-plugin/gateway/gateway.ts +1156 -938
  74. package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
  75. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  76. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  77. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  78. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  79. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  80. package/telegram-plugin/model-unavailable.ts +28 -12
  81. package/telegram-plugin/permission-title.ts +56 -0
  82. package/telegram-plugin/quota-check.ts +19 -41
  83. package/telegram-plugin/scripts/build.mjs +0 -1
  84. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  85. package/telegram-plugin/silence-poke.ts +153 -1
  86. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  87. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  88. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  89. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  90. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  91. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  92. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  93. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  94. package/telegram-plugin/tests/boot-probes.test.ts +27 -22
  95. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  96. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  97. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  98. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  99. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  100. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  101. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  102. package/telegram-plugin/turn-flush-safety.ts +55 -1
  103. package/telegram-plugin/uat/SETUP.md +35 -1
  104. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  105. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  106. package/telegram-plugin/uat/runners/report.ts +150 -0
  107. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  108. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  109. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  110. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  111. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  112. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  113. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  114. package/telegram-plugin/auth-dashboard.ts +0 -1104
  115. package/telegram-plugin/auth-slot-parser.ts +0 -497
  116. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  117. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  118. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  119. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  120. package/telegram-plugin/foreman/foreman.ts +0 -1165
  121. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  122. package/telegram-plugin/foreman/setup-state.ts +0 -239
  123. package/telegram-plugin/foreman/state.ts +0 -203
  124. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  125. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  126. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  127. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  128. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  129. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  130. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  131. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  132. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  133. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  134. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  135. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  136. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  137. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -1,203 +0,0 @@
1
- /**
2
- * Foreman conversation state — SQLite-backed per-chat state for multi-turn
3
- * flows. Survives foreman restarts so a create-agent flow started before a
4
- * restart can resume cleanly.
5
- *
6
- * Location: ~/.switchroom/foreman/state.sqlite
7
- * Override via SWITCHROOM_FOREMAN_DIR env var.
8
- *
9
- * Schema:
10
- * CREATE TABLE IF NOT EXISTS create_flow (
11
- * chat_id TEXT PRIMARY KEY,
12
- * step TEXT NOT NULL, -- 'asked-name' | 'asked-profile' | 'asked-bot-token' | 'asked-oauth-code' | 'done'
13
- * name TEXT,
14
- * profile TEXT,
15
- * bot_token TEXT,
16
- * auth_session_name TEXT,
17
- * login_url TEXT,
18
- * started_at INTEGER NOT NULL,
19
- * updated_at INTEGER NOT NULL
20
- * );
21
- */
22
-
23
- import { Database } from 'bun:sqlite'
24
- import { chmodSync, mkdirSync } from 'fs'
25
- import { homedir } from 'os'
26
- import { join } from 'path'
27
-
28
- // ─── Types ────────────────────────────────────────────────────────────────
29
-
30
- export type CreateFlowStep =
31
- | 'asked-name'
32
- | 'asked-profile'
33
- | 'asked-bot-token'
34
- | 'asked-oauth-code'
35
- | 'done'
36
-
37
- export interface CreateFlowState {
38
- chatId: string
39
- step: CreateFlowStep
40
- name: string | null
41
- profile: string | null
42
- botToken: string | null
43
- authSessionName: string | null
44
- loginUrl: string | null
45
- startedAt: number
46
- updatedAt: number
47
- }
48
-
49
- // ─── DB singleton ─────────────────────────────────────────────────────────
50
-
51
- let _db: Database | null = null
52
-
53
- function getDb(): Database {
54
- if (_db) return _db
55
-
56
- const foremanDir =
57
- process.env.SWITCHROOM_FOREMAN_DIR ?? join(homedir(), '.switchroom', 'foreman')
58
-
59
- // 0o700 on the directory + 0o600 on the DB: this file stores in-flight
60
- // BotFather tokens during /create-agent flows. On a multi-user host,
61
- // default umask (0o022) would leave tokens world-readable otherwise.
62
- mkdirSync(foremanDir, { recursive: true, mode: 0o700 })
63
-
64
- const dbPath = join(foremanDir, 'state.sqlite')
65
- _db = new Database(dbPath)
66
- try {
67
- chmodSync(dbPath, 0o600)
68
- } catch {
69
- // best-effort — fall through if chmod isn't supported
70
- }
71
-
72
- _db.exec(`
73
- CREATE TABLE IF NOT EXISTS create_flow (
74
- chat_id TEXT PRIMARY KEY,
75
- step TEXT NOT NULL,
76
- name TEXT,
77
- profile TEXT,
78
- bot_token TEXT,
79
- auth_session_name TEXT,
80
- login_url TEXT,
81
- started_at INTEGER NOT NULL,
82
- updated_at INTEGER NOT NULL
83
- );
84
- `)
85
-
86
- return _db
87
- }
88
-
89
- // ─── Public API ───────────────────────────────────────────────────────────
90
-
91
- /** Upsert the state for a given chat. */
92
- export function setState(state: CreateFlowState): void {
93
- const db = getDb()
94
- db.prepare(`
95
- INSERT INTO create_flow
96
- (chat_id, step, name, profile, bot_token, auth_session_name, login_url, started_at, updated_at)
97
- VALUES
98
- ($chatId, $step, $name, $profile, $botToken, $authSessionName, $loginUrl, $startedAt, $updatedAt)
99
- ON CONFLICT(chat_id) DO UPDATE SET
100
- step = excluded.step,
101
- name = excluded.name,
102
- profile = excluded.profile,
103
- bot_token = excluded.bot_token,
104
- auth_session_name = excluded.auth_session_name,
105
- login_url = excluded.login_url,
106
- updated_at = excluded.updated_at
107
- `).run({
108
- $chatId: state.chatId,
109
- $step: state.step,
110
- $name: state.name,
111
- $profile: state.profile,
112
- $botToken: state.botToken,
113
- $authSessionName: state.authSessionName,
114
- $loginUrl: state.loginUrl,
115
- $startedAt: state.startedAt,
116
- $updatedAt: state.updatedAt,
117
- })
118
- }
119
-
120
- /** Retrieve the state for a given chat, or null if none exists. */
121
- export function getState(chatId: string): CreateFlowState | null {
122
- const db = getDb()
123
- const row = db.prepare<{
124
- chat_id: string
125
- step: string
126
- name: string | null
127
- profile: string | null
128
- bot_token: string | null
129
- auth_session_name: string | null
130
- login_url: string | null
131
- started_at: number
132
- updated_at: number
133
- }, [string]>(`
134
- SELECT chat_id, step, name, profile, bot_token, auth_session_name, login_url, started_at, updated_at
135
- FROM create_flow
136
- WHERE chat_id = ?
137
- `).get(chatId)
138
-
139
- if (!row) return null
140
-
141
- return {
142
- chatId: row.chat_id,
143
- step: row.step as CreateFlowStep,
144
- name: row.name,
145
- profile: row.profile,
146
- botToken: row.bot_token,
147
- authSessionName: row.auth_session_name,
148
- loginUrl: row.login_url,
149
- startedAt: row.started_at,
150
- updatedAt: row.updated_at,
151
- }
152
- }
153
-
154
- /** Remove the state for a given chat (flow completed or cancelled). */
155
- export function clearState(chatId: string): void {
156
- const db = getDb()
157
- db.prepare('DELETE FROM create_flow WHERE chat_id = ?').run(chatId)
158
- }
159
-
160
- /**
161
- * List all in-progress flows updated within the last `maxAgeMs` ms.
162
- * Used at foreman startup to resume flows that survived a restart.
163
- */
164
- export function listActiveFlows(maxAgeMs = 60 * 60 * 1000): CreateFlowState[] {
165
- const db = getDb()
166
- const cutoff = Date.now() - maxAgeMs
167
- const rows = db.prepare<{
168
- chat_id: string
169
- step: string
170
- name: string | null
171
- profile: string | null
172
- bot_token: string | null
173
- auth_session_name: string | null
174
- login_url: string | null
175
- started_at: number
176
- updated_at: number
177
- }, [number]>(`
178
- SELECT chat_id, step, name, profile, bot_token, auth_session_name, login_url, started_at, updated_at
179
- FROM create_flow
180
- WHERE step != 'done' AND updated_at > ?
181
- ORDER BY updated_at DESC
182
- `).all(cutoff)
183
-
184
- return rows.map(row => ({
185
- chatId: row.chat_id,
186
- step: row.step as CreateFlowStep,
187
- name: row.name,
188
- profile: row.profile,
189
- botToken: row.bot_token,
190
- authSessionName: row.auth_session_name,
191
- loginUrl: row.login_url,
192
- startedAt: row.started_at,
193
- updatedAt: row.updated_at,
194
- }))
195
- }
196
-
197
- /** Reset the DB singleton (useful in tests to avoid sharing state). */
198
- export function _resetDbForTest(): void {
199
- if (_db) {
200
- _db.close()
201
- _db = null
202
- }
203
- }
@@ -1,118 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- buildDashboardText,
4
- formatRateLimitTier,
5
- type DashboardState,
6
- type DashboardSlot,
7
- } from "../auth-dashboard";
8
-
9
- function slot(o: Partial<DashboardSlot> = {}): DashboardSlot {
10
- return { slot: "default", active: false, health: "healthy", quotaExhaustedUntil: null, fiveHourPct: null, sevenDayPct: null, ...o };
11
- }
12
-
13
- /**
14
- * 2026-04-22 — account-identity surface.
15
- *
16
- * Context: a user reauths an agent onto their Max 20x account, but the
17
- * OAuth browser flow gets hijacked by Telegram's in-app WebView (which
18
- * uses a separate cookie jar from their main browser) and the saved
19
- * token ends up for a different account (e.g. a Max 5x) instead. The
20
- * dashboard header showed 'Plan: max' \u2014 indistinguishable between
21
- * 5x and 20x \u2014 so the mismatch was silent until the user hit a quota wall
22
- * hours later.
23
- *
24
- * Fix: surface the full `rateLimitTier` string on the dashboard so a
25
- * wrong-account reauth is IMMEDIATELY visible. User expected max_20x,
26
- * sees max_5x, acts.
27
- *
28
- * Pair fixes (out of scope for these tests but covered in the PR):
29
- * - Auth response now includes a \ud83d\udccb Copy URL button so the user can
30
- * paste into their main browser instead of Telegram's WebView.
31
- * - Auth response text includes a tip about the in-app-browser pitfall.
32
- */
33
-
34
- describe("formatRateLimitTier", () => {
35
- it("shortens default_claude_max_5x to max_5x", () => {
36
- expect(formatRateLimitTier("default_claude_max_5x")).toBe("max_5x");
37
- });
38
-
39
- it("shortens default_claude_max_20x to max_20x", () => {
40
- expect(formatRateLimitTier("default_claude_max_20x")).toBe("max_20x");
41
- });
42
-
43
- it("shortens default_claude_pro to pro", () => {
44
- expect(formatRateLimitTier("default_claude_pro")).toBe("pro");
45
- });
46
-
47
- it("passes unknown tiers through unchanged", () => {
48
- // We don't pretend to understand every future tier string. Passthrough
49
- // means a new Anthropic tier name is visible verbatim until we
50
- // update the formatter.
51
- expect(formatRateLimitTier("team_custom_42")).toBe("team_custom_42");
52
- expect(formatRateLimitTier("enterprise_unlimited")).toBe("enterprise_unlimited");
53
- });
54
-
55
- it("handles empty/null-ish input gracefully", () => {
56
- expect(formatRateLimitTier("")).toBe("");
57
- });
58
- });
59
-
60
- describe("dashboard header surfaces rateLimitTier when present", () => {
61
- const base: DashboardState = {
62
- agent: "lawgpt",
63
- bankId: "lawgpt",
64
- plan: "max",
65
- slots: [slot({ active: true })],
66
- quotaHot: false,
67
- };
68
-
69
- it("shows max_20x when on the bigger plan", () => {
70
- const text = buildDashboardText({ ...base, rateLimitTier: "default_claude_max_20x" });
71
- expect(text).toContain("Plan: <b>max_20x</b>");
72
- // Should NOT just say 'max' \u2014 that's the ambiguous label that
73
- // hid the account mismatch in the incident.
74
- expect(text).not.toContain("Plan: <b>max</b>");
75
- });
76
-
77
- it("shows max_5x when on the smaller plan", () => {
78
- const text = buildDashboardText({ ...base, rateLimitTier: "default_claude_max_5x" });
79
- expect(text).toContain("Plan: <b>max_5x</b>");
80
- });
81
-
82
- it("falls back to plan label when rateLimitTier missing", () => {
83
- const text = buildDashboardText({ ...base, rateLimitTier: null });
84
- expect(text).toContain("Plan: <b>max</b>");
85
- });
86
-
87
- it("falls back to plan label when rateLimitTier undefined", () => {
88
- const text = buildDashboardText({ ...base });
89
- expect(text).toContain("Plan: <b>max</b>");
90
- });
91
-
92
- it("omits Plan: when neither tier nor plan are known", () => {
93
- const text = buildDashboardText({ ...base, plan: null, rateLimitTier: null });
94
- expect(text).not.toContain("Plan:");
95
- expect(text).toContain("Bank: <code>lawgpt</code>");
96
- });
97
-
98
- it("escapes HTML in tier (injection guard)", () => {
99
- const text = buildDashboardText({
100
- ...base,
101
- rateLimitTier: "<script>alert(1)</script>",
102
- });
103
- expect(text).not.toContain("<script>");
104
- expect(text).toContain("&lt;script&gt;");
105
- });
106
-
107
- it("pair assertion: user can distinguish 5x from 20x without hunting", () => {
108
- // Regression anchor: this was the exact confusion in the incident.
109
- // The user saw 'Plan: max' for both accounts and couldn't tell
110
- // which got authorized. With the tier string present, 5x and 20x
111
- // look different in a glance.
112
- const fivex = buildDashboardText({ ...base, rateLimitTier: "default_claude_max_5x" });
113
- const twentyx = buildDashboardText({ ...base, rateLimitTier: "default_claude_max_20x" });
114
- expect(fivex).not.toBe(twentyx);
115
- expect(fivex).toContain("5x");
116
- expect(twentyx).toContain("20x");
117
- });
118
- });
@@ -1,260 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- buildDashboardText,
4
- buildDashboardKeyboard,
5
- parseCallbackData,
6
- encodeCallbackData,
7
- isQuotaHot,
8
- buildRemoveConfirmKeyboard,
9
- QUOTA_HOT_THRESHOLD_PCT,
10
- type DashboardSlot,
11
- } from "../auth-dashboard";
12
-
13
- /**
14
- * Edge-case coverage for the /auth dashboard. Pair with
15
- * auth-dashboard.test.ts which covers the happy paths. This file
16
- * focuses on pathological input, security boundaries, and failure
17
- * modes we should not regress.
18
- */
19
-
20
- function slot(o: Partial<DashboardSlot> = {}): DashboardSlot {
21
- return { slot: "default", active: false, health: "healthy", quotaExhaustedUntil: null, fiveHourPct: null, sevenDayPct: null, ...o };
22
- }
23
-
24
- describe("callback payload — hostile inputs", () => {
25
- const cases: Array<[string, string]> = [
26
- ["shell expansion", "auth:reauth:$(whoami)"],
27
- ["backticks", "auth:reauth:`id`"],
28
- ["semicolons", "auth:reauth:clerk;ls"],
29
- ["pipes", "auth:reauth:clerk|nc attacker"],
30
- ["dot-segments", "auth:use:../../../etc/passwd:default"],
31
- ["null bytes", "auth:reauth:clerk\0xyz"],
32
- ["tabs", "auth:reauth:clerk\ttab"],
33
- ["newlines", "auth:reauth:clerk\nextra"],
34
- ["leading space", "auth:reauth: clerk"],
35
- ["trailing space", "auth:reauth:clerk "],
36
- ["unicode lookalike", "auth:reauth:\u202eevil"],
37
- ["empty agent", "auth:reauth:"],
38
- ["empty slot for use", "auth:use:clerk:"],
39
- ["slot too long (33 chars)", "auth:use:clerk:" + "a".repeat(33)],
40
- ["agent too long (65 chars)", "auth:reauth:" + "a".repeat(65)],
41
- ["slot contains dot", "auth:use:clerk:slot.name"],
42
- ["slot contains slash", "auth:use:clerk:slot/name"],
43
- ["slot contains space", "auth:use:clerk:slot name"],
44
- ];
45
-
46
- it.each(cases)("rejects %s as noop: %s", (_desc, data) => {
47
- expect(parseCallbackData(data)).toEqual({ kind: "noop" });
48
- });
49
-
50
- it("accepts legitimately hyphenated slot names", () => {
51
- expect(parseCallbackData("auth:use:clerk:backup-1")).toMatchObject({ kind: "use", slot: "backup-1" });
52
- });
53
-
54
- it("accepts underscored names", () => {
55
- expect(parseCallbackData("auth:use:clerk:work_personal")).toMatchObject({ kind: "use", slot: "work_personal" });
56
- });
57
- });
58
-
59
- describe("dashboard text — pathological slot states", () => {
60
- it("renders 10 slots without breaking layout", () => {
61
- const slots: DashboardSlot[] = Array.from({ length: 10 }, (_, i) => slot({
62
- slot: `slot-${i}`,
63
- active: i === 0,
64
- }));
65
- const text = buildDashboardText({ agent: "clerk", bankId: "assistant", plan: "max", slots, quotaHot: false });
66
- // All 10 slot names appear in the output
67
- for (let i = 0; i < 10; i++) {
68
- expect(text).toContain(`slot-${i}`);
69
- }
70
- });
71
-
72
- it("renders when ALL slots are quota-exhausted (fallback-impossible state)", () => {
73
- const slots = [
74
- slot({ slot: "default", active: true, health: "quota-exhausted", quotaExhaustedUntil: Date.now() + 60_000 }),
75
- slot({ slot: "backup", active: false, health: "quota-exhausted", quotaExhaustedUntil: Date.now() + 120_000 }),
76
- ];
77
- const text = buildDashboardText({ agent: "clerk", bankId: "assistant", slots, quotaHot: true });
78
- expect(text).toContain("default");
79
- expect(text).toContain("backup");
80
- expect(text.match(/quota-exhausted/g)?.length).toBe(2);
81
- });
82
-
83
- it("renders when a slot has zero quota data (no fiveHour/sevenDay pct)", () => {
84
- const text = buildDashboardText({
85
- agent: "clerk",
86
- bankId: "assistant",
87
- slots: [slot({ active: true, fiveHourPct: null, sevenDayPct: null })],
88
- quotaHot: false,
89
- });
90
- // Shouldn't contain '5h:' or '7d:' if no data
91
- expect(text).not.toContain("5h:");
92
- expect(text).not.toContain("7d:");
93
- });
94
-
95
- it("handles plan=null gracefully", () => {
96
- const text = buildDashboardText({
97
- agent: "clerk",
98
- bankId: "assistant",
99
- plan: null,
100
- slots: [slot({ active: true })],
101
- quotaHot: false,
102
- });
103
- expect(text).toContain("assistant");
104
- expect(text).not.toContain("Plan:");
105
- });
106
-
107
- it("handles 0% utilization (falsy but present)", () => {
108
- const text = buildDashboardText({
109
- agent: "clerk",
110
- bankId: "a",
111
- slots: [slot({ active: true, fiveHourPct: 0, sevenDayPct: 0 })],
112
- quotaHot: false,
113
- });
114
- expect(text).toContain("5h: 0%");
115
- expect(text).toContain("7d: 0%");
116
- });
117
-
118
- it("handles 100% utilization", () => {
119
- const text = buildDashboardText({
120
- agent: "clerk",
121
- bankId: "a",
122
- slots: [slot({ active: true, fiveHourPct: 100, sevenDayPct: 100 })],
123
- quotaHot: true,
124
- });
125
- expect(text).toContain("5h: 100%");
126
- expect(text).toContain("7d: 100%");
127
- });
128
-
129
- it("handles negative reset time (already expired)", () => {
130
- const text = buildDashboardText({
131
- agent: "clerk",
132
- bankId: "a",
133
- slots: [slot({ active: true, health: "quota-exhausted", quotaExhaustedUntil: Date.now() - 60_000 })],
134
- quotaHot: true,
135
- });
136
- // Should clamp to 0 rather than show negative minutes
137
- expect(text).toContain("resets in ~0m");
138
- });
139
-
140
- it("escapes very adversarial agent names", () => {
141
- const text = buildDashboardText({
142
- agent: '</b><script>alert("x")</script><b>',
143
- bankId: "a",
144
- slots: [slot({ active: true })],
145
- quotaHot: false,
146
- });
147
- expect(text).not.toContain("<script>");
148
- expect(text).toContain("&lt;script&gt;");
149
- });
150
- });
151
-
152
- describe("dashboard keyboard — edge cases", () => {
153
- it("caps non-active slot buttons at 3 (prevents runaway rows)", () => {
154
- const slots = [
155
- slot({ slot: "active", active: true }),
156
- slot({ slot: "s1" }),
157
- slot({ slot: "s2" }),
158
- slot({ slot: "s3" }),
159
- slot({ slot: "s4" }),
160
- slot({ slot: "s5" }),
161
- ];
162
- const kb = buildDashboardKeyboard({ agent: "clerk", bankId: "a", slots, quotaHot: false });
163
- const useButtons = kb.inline_keyboard.flat().filter((b) => b.text.startsWith("Use:"));
164
- expect(useButtons.length).toBeLessThanOrEqual(3);
165
- });
166
-
167
- it("no [Reauth active] button when no active slot", () => {
168
- const kb = buildDashboardKeyboard({ agent: "clerk", bankId: "a", slots: [slot({ slot: "a", active: false })], quotaHot: false });
169
- const reauthButton = kb.inline_keyboard.flat().find((b) => b.text.includes("Reauth"));
170
- // There IS still a Reauth button (agent-level, no slot) — expected behaviour
171
- expect(reauthButton).toBeDefined();
172
- // But it should NOT reference a specific slot name
173
- expect(reauthButton!.text).not.toMatch(/Reauth \S+$/);
174
- });
175
-
176
- it("no Use/Remove rows at all when only the active slot exists", () => {
177
- const kb = buildDashboardKeyboard({ agent: "clerk", bankId: "a", slots: [slot({ active: true })], quotaHot: false });
178
- const flat = kb.inline_keyboard.flat();
179
- expect(flat.some((b) => b.text.startsWith("Use:"))).toBe(false);
180
- expect(flat.some((b) => b.text.startsWith("🗑 Remove:"))).toBe(false);
181
- });
182
-
183
- it("empty slot set still shows Add + Refresh", () => {
184
- const kb = buildDashboardKeyboard({ agent: "clerk", bankId: "a", slots: [], quotaHot: false });
185
- const flat = kb.inline_keyboard.flat();
186
- expect(flat.some((b) => b.text.includes("Add slot"))).toBe(true);
187
- expect(flat.some((b) => b.text.includes("Refresh"))).toBe(true);
188
- });
189
-
190
- it("Refresh callback always targets the correct agent", () => {
191
- const kb = buildDashboardKeyboard({ agent: "specific-agent", bankId: "a", slots: [], quotaHot: false });
192
- const refreshBtn = kb.inline_keyboard.flat().find((b) => b.text.includes("Refresh"));
193
- if (refreshBtn && "callback_data" in refreshBtn) {
194
- expect(refreshBtn.callback_data).toBe("auth:refresh:specific-agent");
195
- }
196
- });
197
- });
198
-
199
- describe("isQuotaHot — boundary conditions", () => {
200
- it("99% 5h does NOT trip hot (cold, auto-fallback at 99.5%)", () => {
201
- expect(isQuotaHot([slot({ fiveHourPct: 89 })])).toBe(false);
202
- });
203
-
204
- it("exactly QUOTA_HOT_THRESHOLD_PCT (90%) trips hot", () => {
205
- expect(isQuotaHot([slot({ fiveHourPct: QUOTA_HOT_THRESHOLD_PCT })])).toBe(true);
206
- expect(isQuotaHot([slot({ sevenDayPct: QUOTA_HOT_THRESHOLD_PCT })])).toBe(true);
207
- });
208
-
209
- it("one hot slot among many cold → hot", () => {
210
- expect(isQuotaHot([
211
- slot({ fiveHourPct: 10 }),
212
- slot({ fiveHourPct: 20 }),
213
- slot({ fiveHourPct: 95 }),
214
- slot({ fiveHourPct: 0 }),
215
- ])).toBe(true);
216
- });
217
-
218
- it("quota-exhausted always hot, even with 0% pct", () => {
219
- expect(isQuotaHot([slot({ health: "quota-exhausted", fiveHourPct: 0, sevenDayPct: 0 })])).toBe(true);
220
- });
221
-
222
- it("null utilization doesn't trip", () => {
223
- expect(isQuotaHot([slot({ fiveHourPct: null, sevenDayPct: null })])).toBe(false);
224
- });
225
- });
226
-
227
- describe("remove confirm keyboard — edge cases", () => {
228
- it("cancel button refreshes back to the dashboard (doesn't leave orphan)", () => {
229
- const kb = buildRemoveConfirmKeyboard("clerk", "personal");
230
- const cancelBtn = kb.inline_keyboard.flat().find((b) => b.text.includes("Cancel"));
231
- if (cancelBtn && "callback_data" in cancelBtn) {
232
- expect(cancelBtn.callback_data).toBe("auth:refresh:clerk");
233
- }
234
- });
235
-
236
- it("handles slot names with hyphens/underscores in the confirm label", () => {
237
- const kb = buildRemoveConfirmKeyboard("clerk", "backup_slot-v2");
238
- const confirmBtn = kb.inline_keyboard.flat().find((b) => b.text.startsWith("⚠️"));
239
- expect(confirmBtn?.text).toContain("backup_slot-v2");
240
- });
241
- });
242
-
243
- describe("encode/parse round-trip — lots of identities", () => {
244
- const actions = [
245
- { kind: "refresh", agent: "a" },
246
- { kind: "reauth", agent: "a" },
247
- { kind: "reauth", agent: "a", slot: "b" },
248
- { kind: "add", agent: "a" },
249
- { kind: "use", agent: "a", slot: "b" },
250
- { kind: "rm", agent: "a", slot: "b" },
251
- { kind: "confirm-rm", agent: "a", slot: "b" },
252
- { kind: "fallback", agent: "a" },
253
- { kind: "usage", agent: "a" },
254
- ] as const;
255
-
256
- it.each(actions)("round-trips %s", (action) => {
257
- const encoded = encodeCallbackData(action);
258
- expect(parseCallbackData(encoded)).toEqual(action);
259
- });
260
- });