switchroom 0.11.0 → 0.11.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.
@@ -220,6 +220,16 @@ export interface AuthBrokerClient {
220
220
  agent: string,
221
221
  account: string | null,
222
222
  ): Promise<{ agent: string; account: string | null }>
223
+ /**
224
+ * Live Anthropic quota probe via the broker (#1336). The broker
225
+ * uses its stored accessTokens to hit `/v1/messages` server-side
226
+ * and returns parsed rate-limit headers. Tokens never reach the
227
+ * caller. Per-label results are returned in input order.
228
+ */
229
+ probeQuota(
230
+ accounts: readonly string[],
231
+ timeoutMs?: number,
232
+ ): Promise<{ results: Array<{ label: string; result: import('../quota-check.js').QuotaResult }> }>
223
233
  }
224
234
 
225
235
  export interface AuthCommandContext {
@@ -128,7 +128,6 @@ import {
128
128
  resolveModelUnavailableFromOperatorEvent,
129
129
  } from '../model-unavailable.js'
130
130
  import { runFleetAutoFallback } from '../auto-fallback-fleet.js'
131
- import { fetchAccountQuota } from '../quota-check.js'
132
131
  import { startRestartWatchdog } from './restart-watchdog.js'
133
132
  import { validateStringArray } from './access-validator.js'
134
133
 
@@ -8908,12 +8907,18 @@ async function doFireFleetAutoFallback(triggerAgent: string): Promise<boolean> {
8908
8907
  return false
8909
8908
  }
8910
8909
  const state = await client.listState()
8911
- // Probe live quota for every account in parallel. force:true
8912
- // bypasses the 5-min in-process cache we want the freshest data
8913
- // for the swap decision, not a cached stale read.
8914
- const quotas = await Promise.all(
8915
- state.accounts.map((a) => fetchAccountQuota(a.label, { force: true })),
8916
- )
8910
+ // Probe live quota via the broker (#1336). Pre-fix this read
8911
+ // credentials.json off the agent HOME, which is never populated
8912
+ // post-RFC-H every account looked "no credentials" and the
8913
+ // fallback logic rolled blindly. Broker-routed probes use the
8914
+ // canonical stored tokens.
8915
+ const probeResp = state.accounts.length > 0
8916
+ ? await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
8917
+ : { results: [] }
8918
+ const quotas = state.accounts.map((a) => {
8919
+ const hit = probeResp.results.find((r) => r.label === a.label)
8920
+ return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
8921
+ })
8917
8922
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
8918
8923
  const outcome = await runFleetAutoFallback({
8919
8924
  state,
@@ -9098,17 +9103,31 @@ bot.command("auth", async ctx => {
9098
9103
  isAdmin,
9099
9104
  client,
9100
9105
  chatId,
9101
- // Format 2 enricher — probe live quota for every account in
9102
- // parallel so the snapshot reflects current Anthropic-side
9103
- // utilization, not the broker's potentially-days-stale
9104
- // disk-cached `quota.json`. force:true bypasses the 5-min
9105
- // in-process cache for this call. ~500-800ms per account
9106
- // serial; in parallel ~800ms total for typical 3-account
9107
- // fleets acceptable for an interactive command.
9108
- liveQuotas: async (accounts) =>
9109
- Promise.all(
9110
- accounts.map((a) => fetchAccountQuota(a.label, { force: true })),
9111
- ),
9106
+ // Format 2 enricher — live quota probe via the broker (#1336).
9107
+ // Pre-broker this read `~/.switchroom/accounts/<label>/credentials.json`
9108
+ // off the agent's HOME, which post-RFC-H is never populated (broker
9109
+ // writes only the per-agent .claude/.credentials.json mirror) so
9110
+ // every account showed "no credentials.json or accessToken" in
9111
+ // /auth show. The broker is the source of truth for tokens and now
9112
+ // does the Anthropic probe server-side via `probe-quota`. Tokens
9113
+ // never leave the broker container.
9114
+ liveQuotas: async (accounts) => {
9115
+ try {
9116
+ const { results } = await client.probeQuota(accounts.map((a) => a.label))
9117
+ // Preserve input order (broker also preserves it, but be defensive).
9118
+ return accounts.map((a) => {
9119
+ const hit = results.find((r) => r.label === a.label)
9120
+ if (!hit) return { ok: false as const, reason: "broker returned no result for account" }
9121
+ return hit.result
9122
+ })
9123
+ } catch (err) {
9124
+ // Surface a uniform per-account failure so the dashboard renders
9125
+ // gracefully (label badge stays UNKNOWN) instead of falling back
9126
+ // to the legacy table.
9127
+ const reason = `broker probe-quota failed: ${(err as Error)?.message ?? String(err)}`
9128
+ return accounts.map(() => ({ ok: false as const, reason }))
9129
+ }
9130
+ },
9112
9131
  tz: process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ,
9113
9132
  })
9114
9133
  // Translate the handler's optional keyboard shape into grammy's
@@ -10855,9 +10874,14 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
10855
10874
  return
10856
10875
  }
10857
10876
  const state = await client.listState()
10858
- const quotas = await Promise.all(
10859
- state.accounts.map((a) => fetchAccountQuota(a.label, { force: true })),
10860
- )
10877
+ // Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
10878
+ const probeResp = state.accounts.length > 0
10879
+ ? await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
10880
+ : { results: [] }
10881
+ const quotas = state.accounts.map((a) => {
10882
+ const hit = probeResp.results.find((r) => r.label === a.label)
10883
+ return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
10884
+ })
10861
10885
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
10862
10886
  const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
10863
10887
  '../auth-snapshot-format.js'
@@ -11329,9 +11353,12 @@ bot.command('usage', async ctx => {
11329
11353
  if (client) {
11330
11354
  const state = await client.listState()
11331
11355
  if (state.accounts.length > 0) {
11332
- const quotas = await Promise.all(
11333
- state.accounts.map((a) => fetchAccountQuota(a.label, { force: true })),
11334
- )
11356
+ // Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
11357
+ const probeResp = await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
11358
+ const quotas = state.accounts.map((a) => {
11359
+ const hit = probeResp.results.find((r) => r.label === a.label)
11360
+ return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
11361
+ })
11335
11362
  const { renderAuthSnapshotFormat2, buildSnapshotsFromState } = await import(
11336
11363
  '../auth-snapshot-format.js'
11337
11364
  )
@@ -30,15 +30,23 @@ let _hostdEnabled: boolean | undefined;
30
30
  * Cached for the gateway's lifetime — config doesn't change without a
31
31
  * restart, and the file-read isn't free.
32
32
  *
33
+ * Default-on since the RFC C Phase 2 default-flip: the schema gives
34
+ * `host_control.enabled` a `.default(true)` and the block itself a
35
+ * `.default({})`, so any config parsed through Zod will have the
36
+ * field populated. We use `!== false` semantics here so this helper
37
+ * also matches the default-on view on paths that bypass the parser
38
+ * (tests with partial mocks, code that constructs configs directly).
39
+ *
33
40
  * Best-effort: if the config can't be loaded (gateway running in a
34
41
  * dir where loadConfig fails), returns false so the dispatch helper
35
- * falls through to the legacy spawn path.
42
+ * falls through to the legacy spawn path — better to fail closed
43
+ * than to attempt a hostd RPC against a daemon that might not exist.
36
44
  */
37
45
  export function isHostdEnabled(): boolean {
38
46
  if (_hostdEnabled !== undefined) return _hostdEnabled;
39
47
  try {
40
48
  const cfg = loadSwitchroomConfig();
41
- _hostdEnabled = cfg.host_control?.enabled === true;
49
+ _hostdEnabled = cfg.host_control?.enabled !== false;
42
50
  } catch {
43
51
  _hostdEnabled = false;
44
52
  }