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.
- package/README.md +54 -61
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +285 -45
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +8890 -5560
- package/dist/host-control/main.js +582 -43
- package/dist/vault/approvals/kernel-server.js +276 -47
- package/dist/vault/broker/server.js +333 -69
- 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 +6 -4
- package/profiles/_base/start.sh.hbs +3 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/default/CLAUDE.md +10 -0
- package/profiles/default/CLAUDE.md.hbs +16 -0
- 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/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 +67 -15
- package/skills/switchroom-status/SKILL.md +26 -1
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +9 -5
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- 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 +905 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +23 -37
- package/telegram-plugin/gateway/boot-probes.ts +9 -12
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +1156 -938
- package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +153 -1
- 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-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +27 -22
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +35 -1
- 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/jtbd-interrupt-marker-dm.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/dist/foreman/foreman.js +0 -31358
- 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/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/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- 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/setup-flow.test.ts +0 -510
- 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("<script>");
|
|
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("<script>");
|
|
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
|
-
});
|