switchroom 0.11.1 → 0.12.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 +7 -6
- package/dist/agent-scheduler/index.js +216 -97
- package/dist/auth-broker/index.js +175 -96
- package/dist/cli/drive-write-pretool.mjs +26 -11
- package/dist/cli/switchroom.js +45153 -42663
- package/dist/cli/ui/index.html +1281 -0
- package/dist/host-control/main.js +3628 -309
- package/dist/vault/approvals/kernel-server.js +207 -98
- package/dist/vault/broker/server.js +218 -97
- package/examples/personal-google-workspace-mcp/README.md +8 -3
- package/examples/switchroom.yaml +91 -42
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +76 -36
- package/profiles/default/CLAUDE.md.hbs +4 -2
- package/skills/file-bug/SKILL.md +6 -4
- package/skills/switchroom-cli/SKILL.md +20 -4
- package/skills/switchroom-install/SKILL.md +3 -3
- package/telegram-plugin/auth-snapshot-format.ts +4 -4
- package/telegram-plugin/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +795 -410
- package/telegram-plugin/dist/server.js +162 -161
- package/telegram-plugin/format.ts +71 -0
- package/telegram-plugin/gateway/approval-card.test.ts +18 -18
- package/telegram-plugin/gateway/approval-card.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +2 -2
- package/telegram-plugin/gateway/boot-card.ts +40 -3
- package/telegram-plugin/gateway/boot-probes.ts +71 -27
- package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
- package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
- package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
- package/telegram-plugin/gateway/gateway.ts +193 -22
- package/telegram-plugin/gateway/update-announce.ts +167 -0
- package/telegram-plugin/quota-check.ts +0 -195
- package/telegram-plugin/retry-api-call.ts +24 -0
- package/telegram-plugin/server.ts +8 -5
- package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
- package/telegram-plugin/tests/boot-probes.test.ts +53 -0
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
- package/telegram-plugin/tests/quota-check.test.ts +0 -409
- package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
- package/telegram-plugin/tests/telegram-format.test.ts +84 -1
- package/telegram-plugin/tests/update-announce.test.ts +154 -0
- package/telegram-plugin/welcome-text.ts +1 -8
- package/profiles/default/CLAUDE.md +0 -192
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/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/telegram-plugin/first-paint.ts +0 -225
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/telegram-plugin/server.js +0 -41795
- package/telegram-plugin/tests/html-balanced.ts +0 -63
- package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
- package/telegram-plugin/tool-error-filter.ts +0 -89
|
@@ -256,198 +256,3 @@ export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): s
|
|
|
256
256
|
return lines.join("\n");
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
/* ── Account-level quota probe + short-lived cache ───────────────────── */
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Resolve an account's OAuth access token from
|
|
263
|
-
* `~/.switchroom/accounts/<label>/credentials.json` (new account model
|
|
264
|
-
* — see `reference/share-auth-across-the-fleet.md`). Returns null when
|
|
265
|
-
* the file is missing, malformed, or has no accessToken — caller
|
|
266
|
-
* surfaces a graceful "missing credentials" badge.
|
|
267
|
-
*
|
|
268
|
-
* Exported for unit testing; production callers go through
|
|
269
|
-
* {@link fetchAccountQuota}.
|
|
270
|
-
*/
|
|
271
|
-
export function readAccountAccessToken(
|
|
272
|
-
label: string,
|
|
273
|
-
home: string = (process.env.HOME ?? "/root"),
|
|
274
|
-
): string | null {
|
|
275
|
-
const credPath = join(home, ".switchroom", "accounts", label, "credentials.json");
|
|
276
|
-
if (!existsSync(credPath)) return null;
|
|
277
|
-
try {
|
|
278
|
-
const raw = readFileSync(credPath, "utf-8");
|
|
279
|
-
const parsed = JSON.parse(raw) as {
|
|
280
|
-
claudeAiOauth?: { accessToken?: string };
|
|
281
|
-
};
|
|
282
|
-
const token = parsed.claudeAiOauth?.accessToken?.trim();
|
|
283
|
-
return token && token.length > 0 ? token : null;
|
|
284
|
-
} catch {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Cache key per account label. The cached entry holds the result and
|
|
291
|
-
* the wall-clock timestamp it was fetched at, so the dashboard tap
|
|
292
|
-
* pattern (refresh-on-tap) doesn't trigger a fresh API call within the
|
|
293
|
-
* TTL window. Quota numbers don't move within a few seconds anyway.
|
|
294
|
-
*/
|
|
295
|
-
type AccountQuotaCacheEntry = {
|
|
296
|
-
fetchedAt: number;
|
|
297
|
-
result: QuotaResult;
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
/** TTL for the per-account quota cache — controls when
|
|
301
|
-
* `prefetchAccountQuotaIfStale` re-probes Anthropic and when
|
|
302
|
-
* `fetchAccountQuota`'s cache-bypass kicks in. 5 min: quota numbers
|
|
303
|
-
* don't move within a few minutes for human-scale usage; the
|
|
304
|
-
* prefetch fires on every dashboard render so the cache stays fresh
|
|
305
|
-
* whenever the operator interacts. The dashboard's sync read
|
|
306
|
-
* (`getCachedAccountQuota`) returns last-known data regardless of
|
|
307
|
-
* staleness — see that function's docstring for why. */
|
|
308
|
-
export const ACCOUNT_QUOTA_CACHE_TTL_MS = 5 * 60_000;
|
|
309
|
-
|
|
310
|
-
const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Fetch quota for a global account by label. Wraps {@link fetchQuota}
|
|
314
|
-
* with token-resolution (`~/.switchroom/accounts/<label>/credentials.json`)
|
|
315
|
-
* and a short-lived in-process cache so repeat dashboard taps within
|
|
316
|
-
* the TTL don't re-hit the Anthropic API.
|
|
317
|
-
*
|
|
318
|
-
* Pass `force: true` to bypass the cache (used when the user
|
|
319
|
-
* explicitly taps "📊 Full quota" — they expect a live read).
|
|
320
|
-
*/
|
|
321
|
-
export async function fetchAccountQuota(
|
|
322
|
-
label: string,
|
|
323
|
-
opts: {
|
|
324
|
-
home?: string;
|
|
325
|
-
force?: boolean;
|
|
326
|
-
now?: () => number;
|
|
327
|
-
fetchImpl?: typeof fetch;
|
|
328
|
-
timeoutMs?: number;
|
|
329
|
-
} = {},
|
|
330
|
-
): Promise<QuotaResult> {
|
|
331
|
-
const now = opts.now?.() ?? Date.now();
|
|
332
|
-
if (!opts.force) {
|
|
333
|
-
const cached = accountQuotaCache.get(label);
|
|
334
|
-
if (cached && now - cached.fetchedAt < ACCOUNT_QUOTA_CACHE_TTL_MS) {
|
|
335
|
-
return cached.result;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const token = readAccountAccessToken(label, opts.home);
|
|
340
|
-
if (!token) {
|
|
341
|
-
const result: QuotaResult = {
|
|
342
|
-
ok: false,
|
|
343
|
-
reason: "no credentials.json or accessToken for account",
|
|
344
|
-
};
|
|
345
|
-
accountQuotaCache.set(label, { fetchedAt: now, result });
|
|
346
|
-
return result;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const result = await fetchQuota({
|
|
350
|
-
accessToken: token,
|
|
351
|
-
fetchImpl: opts.fetchImpl,
|
|
352
|
-
timeoutMs: opts.timeoutMs,
|
|
353
|
-
});
|
|
354
|
-
accountQuotaCache.set(label, { fetchedAt: now, result });
|
|
355
|
-
// Note: pre-RFC-H this also persisted to disk via writeAccountQuota
|
|
356
|
-
// (#708) so a gateway restart could re-hydrate without an API call.
|
|
357
|
-
// Post-RFC-H the broker holds canonical quota state and answers
|
|
358
|
-
// via `list-state`, so the gateway's in-process cache is enough.
|
|
359
|
-
return result;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Re-hydrate the in-process account-quota cache from on-disk
|
|
364
|
-
* snapshots written by previous gateway lifetimes (issue #708).
|
|
365
|
-
* Called once at gateway boot so the boot card and the first /auth
|
|
366
|
-
* tap have data instantly — no need to wait for the background
|
|
367
|
-
* prefetch tick.
|
|
368
|
-
*
|
|
369
|
-
* Safe to call repeatedly: each label is set to the disk snapshot's
|
|
370
|
-
* `capturedAt` timestamp so a fresher live probe still wins on
|
|
371
|
-
* `now - fetchedAt < TTL` comparisons. When the disk snapshot is
|
|
372
|
-
* older than the TTL, the cache entry is still seeded — the background
|
|
373
|
-
* prefetch will replace it on the next tap.
|
|
374
|
-
*/
|
|
375
|
-
export function hydrateAccountQuotaCacheFromDisk(
|
|
376
|
-
_labels: ReadonlyArray<string>,
|
|
377
|
-
_home?: string,
|
|
378
|
-
): void {
|
|
379
|
-
// No-op post-RFC-H. The disk-snapshot store this function used to
|
|
380
|
-
// re-hydrate from (per-account quota.json files under
|
|
381
|
-
// ~/.switchroom/accounts/<label>/) is gone — switchroom-auth-broker
|
|
382
|
-
// now owns canonical quota state. Boot-time hydration is the
|
|
383
|
-
// broker's `list-state` call instead. Signature preserved so
|
|
384
|
-
// existing call sites continue to compile while we phase them out.
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** Test/utility helper — wipe the per-account quota cache. The
|
|
388
|
-
* gateway calls this on auth-account-level mutations (account add,
|
|
389
|
-
* account rm, account rename, refresh-accounts tick) so a stale
|
|
390
|
-
* pre-rename label doesn't survive into the dashboard. */
|
|
391
|
-
export function clearAccountQuotaCache(label?: string): void {
|
|
392
|
-
if (label == null) {
|
|
393
|
-
accountQuotaCache.clear();
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
accountQuotaCache.delete(label);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Sync read of the account quota cache. Returns whatever's cached for
|
|
401
|
-
* this label — `null` only when there's NO entry at all. Stale-but-
|
|
402
|
-
* present cache entries are returned on purpose:
|
|
403
|
-
*
|
|
404
|
-
* - The dashboard renders sync; awaiting a fresh probe would block
|
|
405
|
-
* the user-visible message (and a probe can stall on Anthropic
|
|
406
|
-
* latency or network).
|
|
407
|
-
* - Showing yesterday's number is dramatically better UX than
|
|
408
|
-
* showing nothing — quota changes slowly enough that "the cached
|
|
409
|
-
* value" is almost always close to truth.
|
|
410
|
-
* - The background prefetch (`prefetchAccountQuotaIfStale`) keeps
|
|
411
|
-
* the cache fresh across renders. Within the 5-min TTL it
|
|
412
|
-
* no-ops; past the TTL it kicks off a fresh probe whose result
|
|
413
|
-
* is visible on the operator's NEXT render (refresh tap or
|
|
414
|
-
* auto-refresh after an action).
|
|
415
|
-
*
|
|
416
|
-
* Pre-v0.6.11 this function treated stale entries as a miss, which
|
|
417
|
-
* meant the boot-warmed cache vanished after 30s and the operator
|
|
418
|
-
* saw empty quota rows on the first /auth tap of any day after the
|
|
419
|
-
* gateway restart. That's the bug this docstring exists to keep
|
|
420
|
-
* fixed.
|
|
421
|
-
*/
|
|
422
|
-
export function getCachedAccountQuota(
|
|
423
|
-
_label: string,
|
|
424
|
-
_now: number = Date.now(),
|
|
425
|
-
): QuotaResult | null {
|
|
426
|
-
// Note the unused params — we keep the signature stable for callers
|
|
427
|
-
// that pass `now` (test helpers) even though we no longer use it.
|
|
428
|
-
const cached = accountQuotaCache.get(_label);
|
|
429
|
-
if (!cached) return null;
|
|
430
|
-
return cached.result;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Fire-and-forget background prefetch — kicks off
|
|
435
|
-
* `fetchAccountQuota` if the cache is cold/stale and discards the
|
|
436
|
-
* promise. Safe to call on every dashboard render: the cache TTL
|
|
437
|
-
* keeps the API call rate bounded to ~1 per account per 30s
|
|
438
|
-
* regardless of how many times the user taps /auth.
|
|
439
|
-
*
|
|
440
|
-
* Errors are swallowed (the next tap re-tries via the cache miss
|
|
441
|
-
* path); the dashboard's empty quota row is the user-visible
|
|
442
|
-
* "didn't probe yet" signal.
|
|
443
|
-
*/
|
|
444
|
-
export function prefetchAccountQuotaIfStale(
|
|
445
|
-
label: string,
|
|
446
|
-
opts: { home?: string; now?: () => number; fetchImpl?: typeof fetch } = {},
|
|
447
|
-
): void {
|
|
448
|
-
const now = opts.now?.() ?? Date.now();
|
|
449
|
-
const cached = accountQuotaCache.get(label);
|
|
450
|
-
if (cached && now - cached.fetchedAt < ACCOUNT_QUOTA_CACHE_TTL_MS) return;
|
|
451
|
-
// Don't await — background warm.
|
|
452
|
-
void fetchAccountQuota(label, opts).catch(() => {});
|
|
453
|
-
}
|
|
@@ -250,3 +250,27 @@ export async function retryWithThreadFallback<T>(
|
|
|
250
250
|
throw err
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* True when Telegram rejected a message because it couldn't parse the
|
|
256
|
+
* HTML/entities we sent — our prevention (markdownToHtml +
|
|
257
|
+
* sanitizeForTelegram + splitHtmlChunks) let something malformed
|
|
258
|
+
* through anyway. These 400s are deliberately NOT swallowed or retried
|
|
259
|
+
* by `retryApiCall` (only not-modified / not-found / thread-not-found
|
|
260
|
+
* are) — they surface to the caller, which recovers by resending the
|
|
261
|
+
* chunk as plain text (parse_mode unset). Same "caller-level fallback"
|
|
262
|
+
* shape as the THREAD_NOT_FOUND contract above.
|
|
263
|
+
*/
|
|
264
|
+
export function isHtmlParseRejectError(err: unknown): boolean {
|
|
265
|
+
if (!(err instanceof GrammyError) || err.error_code !== 400) return false
|
|
266
|
+
const d = (err.description || '').toLowerCase()
|
|
267
|
+
return (
|
|
268
|
+
d.includes("can't parse entities") ||
|
|
269
|
+
d.includes('can’t parse entities') ||
|
|
270
|
+
d.includes('unsupported start tag') ||
|
|
271
|
+
d.includes('unclosed start tag') ||
|
|
272
|
+
d.includes("can't find end of the entity") ||
|
|
273
|
+
// covers both "expected end tag" and "unexpected end tag"
|
|
274
|
+
d.includes('expected end tag')
|
|
275
|
+
)
|
|
276
|
+
}
|
|
@@ -159,13 +159,16 @@ installPluginLogger()
|
|
|
159
159
|
// missing-feature bugs (no /issues card, no quota notifications, no
|
|
160
160
|
// graceful failover). A clean error is more honest.
|
|
161
161
|
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// `
|
|
162
|
+
// In v0.7+ the gateway is an in-container sidecar started by start.sh
|
|
163
|
+
// (no systemd unit). On a Docker host inspect/recover with
|
|
164
|
+
// `docker logs switchroom-<agent>` / `switchroom agent restart <agent>`;
|
|
165
|
+
// on a legacy non-docker install it's the gateway systemd user unit.
|
|
166
|
+
const recover = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
167
|
+
? 'check `docker logs switchroom-<agent>` then `switchroom agent restart <agent>`'
|
|
168
|
+
: 'run `switchroom setup` to install the gateway, or check `systemctl --user status switchroom-telegram-gateway`'
|
|
165
169
|
process.stderr.write(
|
|
166
170
|
`telegram channel: no gateway socket at ${_gatewaySocket}. ` +
|
|
167
|
-
`
|
|
168
|
-
`\`systemctl --user status switchroom-telegram-gateway\`. Exiting sidecar.\n`,
|
|
171
|
+
`The gateway sidecar is not running — ${recover}. Exiting sidecar.\n`,
|
|
169
172
|
)
|
|
170
173
|
process.exit(1)
|
|
171
174
|
}
|
|
@@ -26,10 +26,39 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
29
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
29
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
30
30
|
import { tmpdir } from 'node:os'
|
|
31
31
|
import { join } from 'node:path'
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Pick an exec-allowed temp root. Some containers (and this one) mount
|
|
35
|
+
* /tmp with `noexec`, which breaks the subprocess fixtures that spawn
|
|
36
|
+
* a small node script as a stand-in for `claude setup-token`. When the
|
|
37
|
+
* default tmpdir is noexec, fall back to a project-local `.test-tmp/`
|
|
38
|
+
* which inherits the project mount's exec bits.
|
|
39
|
+
*/
|
|
40
|
+
function execAllowedTmpdir(): string {
|
|
41
|
+
const def = tmpdir()
|
|
42
|
+
try {
|
|
43
|
+
// Read /proc/mounts and check whether the directory's mount has noexec.
|
|
44
|
+
const mounts = readFileSync('/proc/mounts', 'utf8')
|
|
45
|
+
const noexec = mounts.split('\n').some((line) => {
|
|
46
|
+
const parts = line.split(' ')
|
|
47
|
+
if (parts.length < 4) return false
|
|
48
|
+
const [, mountPoint, , opts] = parts
|
|
49
|
+
return mountPoint === def && opts.split(',').includes('noexec')
|
|
50
|
+
})
|
|
51
|
+
if (!noexec) return def
|
|
52
|
+
} catch {
|
|
53
|
+
return def
|
|
54
|
+
}
|
|
55
|
+
const fallback = join(process.cwd(), '.test-tmp')
|
|
56
|
+
mkdirSync(fallback, { recursive: true })
|
|
57
|
+
return fallback
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const EXEC_TMPDIR = execAllowedTmpdir()
|
|
61
|
+
|
|
33
62
|
import {
|
|
34
63
|
parseAuthCommand,
|
|
35
64
|
handleAuthCommand,
|
|
@@ -51,7 +80,7 @@ import {
|
|
|
51
80
|
let workspace: string
|
|
52
81
|
|
|
53
82
|
beforeEach(() => {
|
|
54
|
-
workspace = mkdtempSync(join(
|
|
83
|
+
workspace = mkdtempSync(join(EXEC_TMPDIR, 'auth-add-flow-test-'))
|
|
55
84
|
pendingAuthAddFlows.clear()
|
|
56
85
|
})
|
|
57
86
|
|
|
@@ -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
|
|
@@ -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', () => {
|