switchroom 0.11.1 → 0.12.1

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 (77) hide show
  1. package/README.md +32 -16
  2. package/dist/agent-scheduler/index.js +216 -97
  3. package/dist/auth-broker/index.js +176 -97
  4. package/dist/cli/drive-write-pretool.mjs +26 -11
  5. package/dist/cli/skill-validate-pretool.mjs +7209 -0
  6. package/dist/cli/switchroom.js +45571 -42642
  7. package/dist/cli/ui/index.html +1281 -0
  8. package/dist/host-control/main.js +3628 -309
  9. package/dist/vault/approvals/kernel-server.js +207 -98
  10. package/dist/vault/broker/server.js +249 -119
  11. package/examples/personal-google-workspace-mcp/README.md +8 -3
  12. package/examples/switchroom.yaml +91 -42
  13. package/package.json +4 -3
  14. package/profiles/_base/start.sh.hbs +76 -36
  15. package/profiles/_shared/agent-self-service.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +4 -2
  17. package/skills/file-bug/SKILL.md +6 -4
  18. package/skills/skill-creator/SKILL.md +52 -0
  19. package/skills/switchroom-cli/SKILL.md +20 -4
  20. package/skills/switchroom-install/SKILL.md +3 -3
  21. package/telegram-plugin/auth-snapshot-format.ts +9 -9
  22. package/telegram-plugin/card-format.ts +3 -3
  23. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  24. package/telegram-plugin/dist/gateway/gateway.js +853 -414
  25. package/telegram-plugin/dist/server.js +162 -161
  26. package/telegram-plugin/format.ts +71 -0
  27. package/telegram-plugin/gateway/access-validator.test.ts +8 -8
  28. package/telegram-plugin/gateway/access-validator.ts +1 -1
  29. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  30. package/telegram-plugin/gateway/approval-card.ts +1 -1
  31. package/telegram-plugin/gateway/auth-command.ts +2 -2
  32. package/telegram-plugin/gateway/boot-card.ts +40 -3
  33. package/telegram-plugin/gateway/boot-probes.ts +114 -30
  34. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  35. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  36. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  37. package/telegram-plugin/gateway/gateway.ts +265 -22
  38. package/telegram-plugin/gateway/update-announce.ts +167 -0
  39. package/telegram-plugin/quota-check.ts +0 -195
  40. package/telegram-plugin/recent-outbound-dedup.ts +1 -1
  41. package/telegram-plugin/registry/turns-schema.ts +1 -1
  42. package/telegram-plugin/retry-api-call.ts +24 -0
  43. package/telegram-plugin/server.ts +8 -5
  44. package/telegram-plugin/tests/auth-add-flow.test.ts +32 -3
  45. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  46. package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
  48. package/telegram-plugin/tests/boot-probes.test.ts +90 -2
  49. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  50. package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
  51. package/telegram-plugin/tests/fleet-state.test.ts +3 -2
  52. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  53. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  54. package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
  55. package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
  56. package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
  57. package/telegram-plugin/tests/secret-detect.test.ts +8 -8
  58. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  59. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  60. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
  61. package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
  62. package/telegram-plugin/welcome-text.ts +1 -8
  63. package/profiles/default/CLAUDE.md +0 -192
  64. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  65. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  66. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  67. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  68. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  69. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  70. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  71. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  72. package/telegram-plugin/first-paint.ts +0 -225
  73. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  74. package/telegram-plugin/server.js +0 -41795
  75. package/telegram-plugin/tests/html-balanced.ts +0 -63
  76. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  77. package/telegram-plugin/tool-error-filter.ts +0 -89
@@ -44,7 +44,7 @@ describe('runFleetAutoFallback', () => {
44
44
  fanned: ['alice', 'bob'],
45
45
  }));
46
46
  const out = await runFleetAutoFallback({
47
- state: state('ken@x', ['ken@x', 'me@x', 'pixsoul@x']),
47
+ state: state('ken@x', ['ken@x', 'me@x', 'you@x']),
48
48
  quotas: [
49
49
  // ken: just blew 5h
50
50
  qOk({
@@ -58,7 +58,7 @@ describe('runFleetAutoFallback', () => {
58
58
  sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
59
59
  representativeClaim: 'seven_day',
60
60
  }),
61
- // pixsoul: healthy 5h/7d
61
+ // you: healthy 5h/7d
62
62
  qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
63
63
  ],
64
64
  setActive,
@@ -69,10 +69,10 @@ describe('runFleetAutoFallback', () => {
69
69
 
70
70
  expect(out.kind).toBe('switched');
71
71
  expect(setActive).toHaveBeenCalledTimes(1);
72
- expect(setActive).toHaveBeenCalledWith('pixsoul@x');
72
+ expect(setActive).toHaveBeenCalledWith('you@x');
73
73
  if (out.kind === 'switched') {
74
74
  expect(out.oldLabel).toBe('ken@x');
75
- expect(out.newLabel).toBe('pixsoul@x');
75
+ expect(out.newLabel).toBe('you@x');
76
76
  expect(out.announcement).toContain('5-hour limit on ken@x');
77
77
  expect(out.announcement).toContain('Triggered by: agent <b>carrie</b>');
78
78
  expect(out.announcement).toContain('plenty of headroom');
@@ -112,7 +112,7 @@ describe('runFleetAutoFallback', () => {
112
112
  it('idempotency: skips swap when active probes healthy (stale event)', async () => {
113
113
  const setActive = vi.fn();
114
114
  const out = await runFleetAutoFallback({
115
- state: state('ken@x', ['ken@x', 'pixsoul@x']),
115
+ state: state('ken@x', ['ken@x', 'you@x']),
116
116
  quotas: [
117
117
  qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
118
118
  qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
@@ -147,14 +147,14 @@ describe('runFleetAutoFallback', () => {
147
147
  it('falls back to a throttling alternative when no healthy one exists', async () => {
148
148
  const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
149
149
  const out = await runFleetAutoFallback({
150
- state: state('ken@x', ['ken@x', 'pixsoul@x']),
150
+ state: state('ken@x', ['ken@x', 'you@x']),
151
151
  quotas: [
152
152
  qOk({
153
153
  fiveHourUtilizationPct: 100,
154
154
  fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
155
155
  representativeClaim: 'five_hour',
156
156
  }),
157
- // pixsoul throttling at 85% but not blocked
157
+ // you throttling at 85% but not blocked
158
158
  qOk({ fiveHourUtilizationPct: 85, sevenDayUtilizationPct: 20 }),
159
159
  ],
160
160
  setActive,
@@ -164,7 +164,7 @@ describe('runFleetAutoFallback', () => {
164
164
  });
165
165
 
166
166
  expect(out.kind).toBe('switched');
167
- expect(setActive).toHaveBeenCalledWith('pixsoul@x');
167
+ expect(setActive).toHaveBeenCalledWith('you@x');
168
168
  if (out.kind === 'switched') {
169
169
  expect(out.announcement).toContain('near limit — watch this');
170
170
  }
@@ -173,7 +173,7 @@ describe('runFleetAutoFallback', () => {
173
173
  it('skips unknown-health (probe failed) when picking a target', async () => {
174
174
  const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
175
175
  const out = await runFleetAutoFallback({
176
- state: state('ken@x', ['ken@x', 'broken@x', 'pixsoul@x']),
176
+ state: state('ken@x', ['ken@x', 'broken@x', 'you@x']),
177
177
  quotas: [
178
178
  qOk({ fiveHourUtilizationPct: 100, fiveHourResetAt: new Date('2026-05-15T05:50:00Z') }),
179
179
  { ok: false, reason: 'HTTP 401' },
@@ -186,7 +186,7 @@ describe('runFleetAutoFallback', () => {
186
186
  });
187
187
 
188
188
  expect(out.kind).toBe('switched');
189
- expect(setActive).toHaveBeenCalledWith('pixsoul@x');
189
+ expect(setActive).toHaveBeenCalledWith('you@x');
190
190
  });
191
191
  });
192
192
 
@@ -292,6 +292,59 @@ describe('probeQuota — #1163: /v1/messages headers path', () => {
292
292
  expect(result.detail).toContain('18% / 7d')
293
293
  })
294
294
 
295
+ it('prefers the broker probe and does NOT do a direct fetch when it succeeds (Option A / #1336)', async () => {
296
+ const directFetch: typeof fetch = async () => {
297
+ throw new Error('direct fetch must not run when the broker probe returns a result')
298
+ }
299
+ const brokerProbe = async () => ({
300
+ ok: true as const,
301
+ data: {
302
+ fiveHourUtilizationPct: 30,
303
+ sevenDayUtilizationPct: 12,
304
+ fiveHourResetAt: null,
305
+ sevenDayResetAt: null,
306
+ representativeClaim: null,
307
+ overageStatus: null,
308
+ overageDisabledReason: null,
309
+ },
310
+ })
311
+ const result = await probeQuota(claudeDir, agentDir, directFetch, { brokerProbe })
312
+ expect(result.status).toBe('ok')
313
+ expect(result.detail).toContain('30% / 5h')
314
+ expect(result.detail).toContain('12% / 7d')
315
+ })
316
+
317
+ it('falls back to a direct probe when the broker probe returns null (broker unreachable)', async () => {
318
+ const headers = new Headers({
319
+ 'anthropic-ratelimit-unified-5h-utilization': '0.55',
320
+ 'anthropic-ratelimit-unified-7d-utilization': '0.22',
321
+ })
322
+ const directFetch: typeof fetch = async () =>
323
+ new Response('{}', { status: 200, headers }) as Response
324
+ const brokerProbe = async () => null
325
+
326
+ const result = await probeQuota(claudeDir, agentDir, directFetch, { brokerProbe })
327
+ expect(result.status).toBe('ok')
328
+ expect(result.detail).toContain('55% / 5h')
329
+ expect(result.detail).toContain('22% / 7d')
330
+ })
331
+
332
+ it('falls back to a direct probe when the broker probe throws', async () => {
333
+ const headers = new Headers({
334
+ 'anthropic-ratelimit-unified-5h-utilization': '0.10',
335
+ 'anthropic-ratelimit-unified-7d-utilization': '0.05',
336
+ })
337
+ const directFetch: typeof fetch = async () =>
338
+ new Response('{}', { status: 200, headers }) as Response
339
+ const brokerProbe = async () => {
340
+ throw new Error('broker UDS connect failed')
341
+ }
342
+
343
+ const result = await probeQuota(claudeDir, agentDir, directFetch, { brokerProbe })
344
+ expect(result.status).toBe('ok')
345
+ expect(result.detail).toContain('10% / 5h')
346
+ })
347
+
295
348
  it('surfaces auth rejection with the RFC-H replace-account hint on 403', async () => {
296
349
  const fakeFetch: typeof fetch = async () =>
297
350
  new Response(null, { status: 403 }) as Response
@@ -830,7 +883,7 @@ describe('probeSkills', () => {
830
883
  expect(result.detail).toContain('no skills dir')
831
884
  })
832
885
 
833
- it('returns ok with count when every skill resolves', async () => {
886
+ it('returns ok with every skill listed under Switchroom bucket when no overlay', async () => {
834
887
  const fs = makeSkillsFs(
835
888
  { [skillsDir]: ['simplify', 'review'] },
836
889
  new Set([
@@ -840,7 +893,40 @@ describe('probeSkills', () => {
840
893
  )
841
894
  const result = await probeSkills(agentDir, { fs })
842
895
  expect(result.status).toBe('ok')
843
- expect(result.detail).toContain('2 resolved')
896
+ expect(result.detail).toBe('Switchroom: review, simplify')
897
+ })
898
+
899
+ it('buckets overlay-installed skills under Agent', async () => {
900
+ const overlayDir = '/state/agent/skills.d'
901
+ const fs = makeSkillsFs(
902
+ {
903
+ [skillsDir]: ['simplify', 'review', 'webapp-testing'],
904
+ [overlayDir]: ['webapp-testing.yaml'],
905
+ },
906
+ new Set([
907
+ `${skillsDir}/simplify`, `${skillsDir}/simplify/SKILL.md`,
908
+ `${skillsDir}/review`, `${skillsDir}/review/SKILL.md`,
909
+ `${skillsDir}/webapp-testing`, `${skillsDir}/webapp-testing/SKILL.md`,
910
+ overlayDir,
911
+ ]),
912
+ )
913
+ const result = await probeSkills(agentDir, { fs })
914
+ expect(result.status).toBe('ok')
915
+ expect(result.detail).toBe('Switchroom: review, simplify · Agent: webapp-testing')
916
+ })
917
+
918
+ it('lists every skill without a +N more truncation in the healthy case', async () => {
919
+ const names = Array.from({ length: 12 }, (_, i) => `skill-${i.toString().padStart(2, '0')}`)
920
+ const fileSet = new Set<string>()
921
+ for (const n of names) {
922
+ fileSet.add(`${skillsDir}/${n}`)
923
+ fileSet.add(`${skillsDir}/${n}/SKILL.md`)
924
+ }
925
+ const fs = makeSkillsFs({ [skillsDir]: names }, fileSet)
926
+ const result = await probeSkills(agentDir, { fs })
927
+ expect(result.status).toBe('ok')
928
+ expect(result.detail).not.toContain('+')
929
+ for (const n of names) expect(result.detail).toContain(n)
844
930
  })
845
931
 
846
932
  it('degraded when at least one symlink dangles, names them up to cap', async () => {
@@ -859,6 +945,8 @@ describe('probeSkills', () => {
859
945
  expect(result.detail).toContain('also-gone')
860
946
  expect(result.detail).toContain('+1 more')
861
947
  expect(result.detail).not.toContain('and-this')
948
+ // resolved skills still surface alongside the dangling summary
949
+ expect(result.detail).toContain('Switchroom: review, simplify')
862
950
  })
863
951
 
864
952
  it('returns ok when entries dir is empty', async () => {
@@ -7,7 +7,7 @@
7
7
  * via integration tests; here we keep it to pure unit coverage.
8
8
  */
9
9
 
10
- import { describe, it, expect, vi } from 'vitest'
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
11
11
  import {
12
12
  escapeHtmlForTg,
13
13
  preBlock,
@@ -20,6 +20,28 @@ import {
20
20
  } from '../shared/bot-runtime.js'
21
21
  import type { Context } from 'grammy'
22
22
 
23
+ // ─── env hygiene ─────────────────────────────────────────────────────────
24
+ // The exec factories read SWITCHROOM_CONFIG and prepend `--config <path>` to
25
+ // argv. When the test runner inherits SWITCHROOM_CONFIG from the environment
26
+ // (e.g. switchroom-managed shells), this leaks into tests that use `echo`
27
+ // as the cliPath and breaks stdout assertions. Clear before each test and
28
+ // restore after.
29
+
30
+ let savedSwitchroomConfig: string | undefined
31
+
32
+ beforeEach(() => {
33
+ savedSwitchroomConfig = process.env.SWITCHROOM_CONFIG
34
+ delete process.env.SWITCHROOM_CONFIG
35
+ })
36
+
37
+ afterEach(() => {
38
+ if (savedSwitchroomConfig !== undefined) {
39
+ process.env.SWITCHROOM_CONFIG = savedSwitchroomConfig
40
+ } else {
41
+ delete process.env.SWITCHROOM_CONFIG
42
+ }
43
+ })
44
+
23
45
  // ─── escapeHtmlForTg ─────────────────────────────────────────────────────
24
46
 
25
47
  describe('escapeHtmlForTg', () => {
@@ -339,7 +339,7 @@
339
339
 
340
340
 
341
341
 
342
- 
342
+ 
343
343
  ❯  
344
344
  ────────────────────────────────────────────────────────────────────────────────
345
345
  ⏵⏵accepteditson(shift+tabtocycle)·esctointerrupt
@@ -155,8 +155,9 @@ describe('sanitiseToolArg', () => {
155
155
  expect(sanitiseToolArg('Write', { file_path: '/tmp/out.json' })).toBe('out.json')
156
156
  })
157
157
  it('redacts bearer-token-like strings in Bash commands', () => {
158
- const out = sanitiseToolArg('Bash', { command: 'curl -H "Authorization: Bearer sk-ant-1234567890abcdef" https://x' })
159
- expect(out).not.toContain('sk-ant-1234567890abcdef')
158
+ const tok = ['sk-ant-', '1234567890abcdef'].join('')
159
+ const out = sanitiseToolArg('Bash', { command: `curl -H "Authorization: Bearer ${tok}" https://x` })
160
+ expect(out).not.toContain(tok)
160
161
  expect(out.toLowerCase()).toContain('redacted')
161
162
  })
162
163
  it('returns empty string when no recognisable arg', () => {
@@ -7,12 +7,6 @@ import {
7
7
  formatQuotaBlock,
8
8
  formatQuotaLine,
9
9
  parseQuotaHeaders,
10
- readAccountAccessToken,
11
- fetchAccountQuota,
12
- getCachedAccountQuota,
13
- prefetchAccountQuotaIfStale,
14
- clearAccountQuotaCache,
15
- ACCOUNT_QUOTA_CACHE_TTL_MS,
16
10
  } from '../quota-check.js'
17
11
 
18
12
  function makeTempClaudeDir(token: string | null): string {
@@ -189,409 +183,6 @@ describe('fetchQuota', () => {
189
183
  })
190
184
  })
191
185
 
192
- // ─── Account-level helpers ────────────────────────────────────────────
193
-
194
- /** Build a fake $HOME with `~/.switchroom/accounts/<label>/credentials.json`. */
195
- function makeAccountHome(
196
- accounts: Record<string, { accessToken?: string }>,
197
- ): string {
198
- const home = mkdtempSync(join(tmpdir(), 'quota-acct-test-'))
199
- for (const [label, creds] of Object.entries(accounts)) {
200
- const dir = join(home, '.switchroom', 'accounts', label)
201
- mkdirSync(dir, { recursive: true })
202
- if (creds.accessToken !== undefined) {
203
- writeFileSync(
204
- join(dir, 'credentials.json'),
205
- JSON.stringify({ claudeAiOauth: { accessToken: creds.accessToken } }),
206
- )
207
- }
208
- }
209
- return home
210
- }
211
-
212
- describe('readAccountAccessToken', () => {
213
- it('returns the access token from credentials.json', () => {
214
- const home = makeAccountHome({
215
- 'pixsoul@gmail.com': { accessToken: 'sk-ant-oat01-fake' },
216
- })
217
- try {
218
- expect(readAccountAccessToken('pixsoul@gmail.com', home)).toBe(
219
- 'sk-ant-oat01-fake',
220
- )
221
- } finally {
222
- rmSync(home, { recursive: true, force: true })
223
- }
224
- })
225
-
226
- it('returns null when the account dir is missing', () => {
227
- const home = makeAccountHome({})
228
- try {
229
- expect(readAccountAccessToken('absent', home)).toBeNull()
230
- } finally {
231
- rmSync(home, { recursive: true, force: true })
232
- }
233
- })
234
-
235
- it('returns null when accessToken is empty', () => {
236
- const home = makeAccountHome({
237
- 'empty@example.com': { accessToken: '' },
238
- })
239
- try {
240
- expect(readAccountAccessToken('empty@example.com', home)).toBeNull()
241
- } finally {
242
- rmSync(home, { recursive: true, force: true })
243
- }
244
- })
245
-
246
- it('returns null when credentials.json is malformed', () => {
247
- const home = mkdtempSync(join(tmpdir(), 'quota-acct-bad-'))
248
- const dir = join(home, '.switchroom', 'accounts', 'broken')
249
- mkdirSync(dir, { recursive: true })
250
- writeFileSync(join(dir, 'credentials.json'), '{not json')
251
- try {
252
- expect(readAccountAccessToken('broken', home)).toBeNull()
253
- } finally {
254
- rmSync(home, { recursive: true, force: true })
255
- }
256
- })
257
- })
258
-
259
- describe('fetchAccountQuota — cache + token resolution', () => {
260
- beforeEach(() => {
261
- clearAccountQuotaCache()
262
- })
263
-
264
- it('fetches once, returns cached on subsequent calls within TTL', async () => {
265
- const home = makeAccountHome({
266
- 'work@example.com': { accessToken: 'tok' },
267
- })
268
- let callCount = 0
269
- const fakeFetch = async () => {
270
- callCount++
271
- return new Response('{}', {
272
- status: 200,
273
- headers: {
274
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
275
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
276
- },
277
- })
278
- }
279
- try {
280
- const r1 = await fetchAccountQuota('work@example.com', {
281
- home,
282
- fetchImpl: fakeFetch as typeof fetch,
283
- })
284
- const r2 = await fetchAccountQuota('work@example.com', {
285
- home,
286
- fetchImpl: fakeFetch as typeof fetch,
287
- })
288
- expect(r1.ok).toBe(true)
289
- expect(r2.ok).toBe(true)
290
- expect(callCount).toBe(1) // cache hit on the second call
291
- } finally {
292
- rmSync(home, { recursive: true, force: true })
293
- }
294
- })
295
-
296
- it('force=true bypasses the cache', async () => {
297
- const home = makeAccountHome({
298
- 'work@example.com': { accessToken: 'tok' },
299
- })
300
- let callCount = 0
301
- const fakeFetch = async () => {
302
- callCount++
303
- return new Response('{}', {
304
- status: 200,
305
- headers: {
306
- 'anthropic-ratelimit-unified-5h-utilization': '0.5',
307
- 'anthropic-ratelimit-unified-7d-utilization': '0.5',
308
- },
309
- })
310
- }
311
- try {
312
- await fetchAccountQuota('work@example.com', {
313
- home,
314
- fetchImpl: fakeFetch as typeof fetch,
315
- })
316
- await fetchAccountQuota('work@example.com', {
317
- home,
318
- fetchImpl: fakeFetch as typeof fetch,
319
- force: true,
320
- })
321
- expect(callCount).toBe(2)
322
- } finally {
323
- rmSync(home, { recursive: true, force: true })
324
- }
325
- })
326
-
327
- it('caches missing-credentials failures so the API is not pinged', async () => {
328
- const home = makeAccountHome({})
329
- let callCount = 0
330
- const fakeFetch = async () => {
331
- callCount++
332
- return new Response('{}')
333
- }
334
- const r1 = await fetchAccountQuota('absent', {
335
- home,
336
- fetchImpl: fakeFetch as typeof fetch,
337
- })
338
- const r2 = await fetchAccountQuota('absent', {
339
- home,
340
- fetchImpl: fakeFetch as typeof fetch,
341
- })
342
- expect(r1.ok).toBe(false)
343
- expect(r2.ok).toBe(false)
344
- expect(callCount).toBe(0) // never reached fetch — token resolution failed first
345
- })
346
-
347
- it('cache miss after TTL triggers a fresh fetch', async () => {
348
- const home = makeAccountHome({
349
- 'work@example.com': { accessToken: 'tok' },
350
- })
351
- let callCount = 0
352
- const fakeFetch = async () => {
353
- callCount++
354
- return new Response('{}', {
355
- status: 200,
356
- headers: {
357
- 'anthropic-ratelimit-unified-5h-utilization': '0.3',
358
- 'anthropic-ratelimit-unified-7d-utilization': '0.3',
359
- },
360
- })
361
- }
362
- let nowVal = 1_000_000
363
- const now = () => nowVal
364
- try {
365
- await fetchAccountQuota('work@example.com', {
366
- home,
367
- fetchImpl: fakeFetch as typeof fetch,
368
- now,
369
- })
370
- // Step time past the TTL.
371
- nowVal += ACCOUNT_QUOTA_CACHE_TTL_MS + 1
372
- await fetchAccountQuota('work@example.com', {
373
- home,
374
- fetchImpl: fakeFetch as typeof fetch,
375
- now,
376
- })
377
- expect(callCount).toBe(2)
378
- } finally {
379
- rmSync(home, { recursive: true, force: true })
380
- }
381
- })
382
-
383
- // Removed in RFC H: per-account quota.json disk persistence is gone.
384
- // switchroom-auth-broker holds canonical quota state and exposes it
385
- // via list-state; the gateway's in-process cache is enough between
386
- // restarts (and the broker survives gateway restarts, so the state
387
- // is preserved at the broker side anyway).
388
- })
389
-
390
- describe('getCachedAccountQuota + prefetchAccountQuotaIfStale', () => {
391
- beforeEach(() => {
392
- clearAccountQuotaCache()
393
- })
394
-
395
- it('returns null on a cold cache, populates after a fetch', async () => {
396
- const home = makeAccountHome({
397
- 'work@example.com': { accessToken: 'tok' },
398
- })
399
- try {
400
- expect(getCachedAccountQuota('work@example.com')).toBeNull()
401
- await fetchAccountQuota('work@example.com', {
402
- home,
403
- fetchImpl: (async () =>
404
- new Response('{}', {
405
- status: 200,
406
- headers: {
407
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
408
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
409
- },
410
- })) as typeof fetch,
411
- })
412
- const cached = getCachedAccountQuota('work@example.com')
413
- expect(cached?.ok).toBe(true)
414
- if (cached?.ok) {
415
- expect(Math.round(cached.data.fiveHourUtilizationPct)).toBe(42)
416
- }
417
- } finally {
418
- rmSync(home, { recursive: true, force: true })
419
- }
420
- })
421
-
422
- it("returns stale entries verbatim — staleness is the prefetch path's concern, not the read path's (v0.6.11)", async () => {
423
- // The dashboard renders sync. Pre-v0.6.11 this function treated
424
- // stale cache as a miss → the boot-warmed cache vanished after
425
- // 30s and the operator saw empty quota rows on the first /auth
426
- // tap of any session past that window. Now stale-but-present
427
- // entries are returned; the background prefetch keeps the cache
428
- // fresh across renders.
429
- const home = makeAccountHome({
430
- 'work@example.com': { accessToken: 'tok' },
431
- })
432
- try {
433
- const nowVal = 1_000_000
434
- await fetchAccountQuota('work@example.com', {
435
- home,
436
- fetchImpl: (async () =>
437
- new Response('{}', {
438
- status: 200,
439
- headers: {
440
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
441
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
442
- },
443
- })) as typeof fetch,
444
- now: () => nowVal,
445
- })
446
- // Within TTL — cached.
447
- const fresh = getCachedAccountQuota('work@example.com', nowVal)
448
- expect(fresh).not.toBeNull()
449
- // Past TTL — STILL returned, identical to the within-TTL read.
450
- const after = nowVal + ACCOUNT_QUOTA_CACHE_TTL_MS + 1
451
- const stale = getCachedAccountQuota('work@example.com', after)
452
- expect(stale).not.toBeNull()
453
- expect(stale).toEqual(fresh)
454
- } finally {
455
- rmSync(home, { recursive: true, force: true })
456
- }
457
- })
458
-
459
- it('returns null when the label has never been probed', async () => {
460
- // The only "no data" path: the cache map has no entry. After
461
- // the first probe the entry persists for the lifetime of the
462
- // gateway process, regardless of staleness.
463
- expect(getCachedAccountQuota('never-probed@example.com')).toBeNull()
464
- })
465
-
466
- it('prefetchAccountQuotaIfStale is a noop when cache is fresh', async () => {
467
- const home = makeAccountHome({
468
- 'work@example.com': { accessToken: 'tok' },
469
- })
470
- let callCount = 0
471
- const fakeFetch = (async () => {
472
- callCount++
473
- return new Response('{}', {
474
- status: 200,
475
- headers: {
476
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
477
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
478
- },
479
- })
480
- }) as typeof fetch
481
- try {
482
- await fetchAccountQuota('work@example.com', { home, fetchImpl: fakeFetch })
483
- expect(callCount).toBe(1)
484
- // Fresh cache — prefetch should not fire.
485
- prefetchAccountQuotaIfStale('work@example.com', { home, fetchImpl: fakeFetch })
486
- // Yield once to let any spurious microtasks settle.
487
- await Promise.resolve()
488
- expect(callCount).toBe(1)
489
- } finally {
490
- rmSync(home, { recursive: true, force: true })
491
- }
492
- })
493
- })
494
-
495
- describe('regression: boot-warm + delayed sync-read (v0.6.11)', () => {
496
- // The bug: gateway boot-warm fills the cache; cache TTL elapses;
497
- // dashboard's sync read returns null; operator sees empty quota
498
- // rows on first /auth tap of the session past TTL. Fix: sync read
499
- // returns last-known data regardless of staleness; prefetch path
500
- // owns the freshness contract. Pin both legs so a future TTL
501
- // tweak can't silently re-introduce the bug.
502
- it('returns last-known data even after multiple TTL windows have elapsed', async () => {
503
- clearAccountQuotaCache()
504
- const home = makeAccountHome({
505
- 'pixsoul@gmail.com': { accessToken: 'tok' },
506
- })
507
- try {
508
- const t0 = 1_000_000
509
- // Boot-warm: probe completes at t0.
510
- await fetchAccountQuota('pixsoul@gmail.com', {
511
- home,
512
- fetchImpl: (async () =>
513
- new Response('{}', {
514
- status: 200,
515
- headers: {
516
- 'anthropic-ratelimit-unified-5h-utilization': '0.04',
517
- 'anthropic-ratelimit-unified-7d-utilization': '0.78',
518
- },
519
- })) as typeof fetch,
520
- now: () => t0,
521
- })
522
- // 8.5 minutes later (the screenshot-reproduction window): the
523
- // dashboard fetches state. Sync read returns the boot-warmed
524
- // values rather than null.
525
- const tDashboard = t0 + 8.5 * 60_000
526
- const cached = getCachedAccountQuota('pixsoul@gmail.com', tDashboard)
527
- expect(cached).not.toBeNull()
528
- if (cached?.ok) {
529
- expect(cached.data.fiveHourUtilizationPct).toBe(4)
530
- expect(cached.data.sevenDayUtilizationPct).toBe(78)
531
- } else {
532
- throw new Error('expected ok=true cached entry')
533
- }
534
- } finally {
535
- rmSync(home, { recursive: true, force: true })
536
- }
537
- })
538
-
539
- it('prefetchAccountQuotaIfStale re-probes once the TTL has elapsed', async () => {
540
- clearAccountQuotaCache()
541
- const home = makeAccountHome({
542
- 'work@example.com': { accessToken: 'tok' },
543
- })
544
- try {
545
- const t0 = 1_000_000
546
- let fetchCount = 0
547
- const counterFetch: typeof fetch = async () => {
548
- fetchCount++
549
- return new Response('{}', {
550
- status: 200,
551
- headers: {
552
- 'anthropic-ratelimit-unified-5h-utilization': '0.10',
553
- 'anthropic-ratelimit-unified-7d-utilization': '0.20',
554
- },
555
- })
556
- }
557
- // First probe seeds the cache.
558
- await fetchAccountQuota('work@example.com', {
559
- home,
560
- fetchImpl: counterFetch,
561
- now: () => t0,
562
- })
563
- expect(fetchCount).toBe(1)
564
- // Within TTL: prefetch is a no-op.
565
- prefetchAccountQuotaIfStale('work@example.com', {
566
- home,
567
- fetchImpl: counterFetch,
568
- now: () => t0 + 60_000,
569
- })
570
- // Give microtask queue a chance — should still be 1.
571
- await new Promise((r) => setTimeout(r, 5))
572
- expect(fetchCount).toBe(1)
573
- // Past TTL: prefetch fires a fresh probe.
574
- prefetchAccountQuotaIfStale('work@example.com', {
575
- home,
576
- fetchImpl: counterFetch,
577
- now: () => t0 + ACCOUNT_QUOTA_CACHE_TTL_MS + 1,
578
- })
579
- // Wait for the fire-and-forget probe to complete.
580
- await new Promise((r) => setTimeout(r, 20))
581
- expect(fetchCount).toBe(2)
582
- } finally {
583
- rmSync(home, { recursive: true, force: true })
584
- }
585
- })
586
-
587
- it('cache TTL is at least 1 minute — short TTLs cause empty-row regressions', () => {
588
- // Pre-v0.6.11 was 30s, which made the boot-warm useless. If a
589
- // future PR drops it below 60s, this test catches it before the
590
- // empty-row regression hits production.
591
- expect(ACCOUNT_QUOTA_CACHE_TTL_MS).toBeGreaterThanOrEqual(60_000)
592
- })
593
- })
594
-
595
186
  describe('fetchQuota — accessToken parameter', () => {
596
187
  it('accepts a direct accessToken instead of a config dir', async () => {
597
188
  const fakeFetch = async (_url: unknown, init?: RequestInit) => {