switchroom 0.11.0 → 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.
Files changed (62) hide show
  1. package/README.md +7 -6
  2. package/dist/agent-scheduler/index.js +218 -99
  3. package/dist/auth-broker/index.js +300 -99
  4. package/dist/cli/drive-write-pretool.mjs +45 -12
  5. package/dist/cli/switchroom.js +44972 -42457
  6. package/dist/cli/ui/index.html +1281 -0
  7. package/dist/host-control/main.js +3630 -311
  8. package/dist/vault/approvals/kernel-server.js +209 -100
  9. package/dist/vault/broker/server.js +220 -99
  10. package/examples/personal-google-workspace-mcp/README.md +8 -3
  11. package/examples/switchroom.yaml +91 -42
  12. package/package.json +2 -2
  13. package/profiles/_base/start.sh.hbs +76 -36
  14. package/profiles/default/CLAUDE.md.hbs +4 -2
  15. package/skills/file-bug/SKILL.md +6 -4
  16. package/skills/switchroom-cli/SKILL.md +20 -4
  17. package/skills/switchroom-install/SKILL.md +3 -3
  18. package/telegram-plugin/auth-snapshot-format.ts +4 -4
  19. package/telegram-plugin/auto-fallback-fleet.ts +4 -4
  20. package/telegram-plugin/card-format.ts +3 -3
  21. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  22. package/telegram-plugin/dist/gateway/gateway.js +1029 -628
  23. package/telegram-plugin/dist/server.js +162 -161
  24. package/telegram-plugin/format.ts +71 -0
  25. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  26. package/telegram-plugin/gateway/approval-card.ts +1 -1
  27. package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
  28. package/telegram-plugin/gateway/auth-command.ts +12 -2
  29. package/telegram-plugin/gateway/boot-card.ts +40 -3
  30. package/telegram-plugin/gateway/boot-probes.ts +71 -27
  31. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  32. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  33. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  34. package/telegram-plugin/gateway/gateway.ts +244 -46
  35. package/telegram-plugin/gateway/hostd-dispatch.ts +10 -2
  36. package/telegram-plugin/gateway/update-announce.ts +167 -0
  37. package/telegram-plugin/quota-check.ts +0 -195
  38. package/telegram-plugin/retry-api-call.ts +24 -0
  39. package/telegram-plugin/server.ts +8 -5
  40. package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
  41. package/telegram-plugin/tests/boot-probes.test.ts +53 -0
  42. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  43. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  44. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  45. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  46. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  47. package/telegram-plugin/welcome-text.ts +1 -8
  48. package/profiles/default/CLAUDE.md +0 -192
  49. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  50. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  51. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  53. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  54. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  55. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  56. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  57. package/telegram-plugin/first-paint.ts +0 -225
  58. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  59. package/telegram-plugin/server.js +0 -41795
  60. package/telegram-plugin/tests/html-balanced.ts +0 -63
  61. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  62. package/telegram-plugin/tool-error-filter.ts +0 -89
@@ -62,6 +62,7 @@ import {
62
62
  createRetryApiCall,
63
63
  createSwallowingRetryApiCall,
64
64
  retryWithThreadFallback,
65
+ isHtmlParseRejectError,
65
66
  } from '../retry-api-call.js'
66
67
  import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
67
68
  import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
@@ -128,7 +129,6 @@ import {
128
129
  resolveModelUnavailableFromOperatorEvent,
129
130
  } from '../model-unavailable.js'
130
131
  import { runFleetAutoFallback } from '../auto-fallback-fleet.js'
131
- import { fetchAccountQuota } from '../quota-check.js'
132
132
  import { startRestartWatchdog } from './restart-watchdog.js'
133
133
  import { validateStringArray } from './access-validator.js'
134
134
 
@@ -138,7 +138,7 @@ import { validateStringArray } from './access-validator.js'
138
138
  * identical envelope shapes.
139
139
  */
140
140
  const REPLY_TO_TEXT_MAX = 200
141
- import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace } from '../format.js'
141
+ import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
142
142
  import {
143
143
  validateInlineKeyboard,
144
144
  type AnyButton,
@@ -214,6 +214,7 @@ import {
214
214
  import {
215
215
  fetchQuota,
216
216
  formatQuotaBlock,
217
+ type QuotaResult,
217
218
  } from '../quota-check.js'
218
219
  import {
219
220
  loadLockout,
@@ -302,6 +303,7 @@ import {
302
303
  } from './boot-card.js'
303
304
  import { determineRestartReason } from './boot-reason.js'
304
305
  import { shouldSkipDuplicateBootCard, type RestartReason } from './boot-card.js'
306
+ import { maybeRenderUpdateAnnouncement } from './update-announce.js'
305
307
  import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
306
308
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
307
309
  import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
@@ -2776,6 +2778,12 @@ const ipcServer: IpcServer = createIpcServer({
2776
2778
  // second bridge-reconnect in the same lifetime can't race against
2777
2779
  // an in-flight sendMessage here either (#489).
2778
2780
  bootCardPending = true
2781
+ // PR C: surface the most recent terminal update_apply audit
2782
+ // row if it lands within the lookback window AND no other
2783
+ // boot has claimed it. Cheap (one file read + one O_EXCL).
2784
+ const updateOutcomeLine = (() => {
2785
+ try { return maybeRenderUpdateAnnouncement() ?? undefined } catch { return undefined }
2786
+ })()
2779
2787
  startBootCard(chatId, threadId, botApiForCard, {
2780
2788
  agentName: agentDisplayName,
2781
2789
  agentSlug,
@@ -2786,8 +2794,10 @@ const ipcServer: IpcServer = createIpcServer({
2786
2794
  restartAgeMs: markerAgeMs,
2787
2795
  restartReasonDetail: cleanMarker?.reason,
2788
2796
  loadAccounts: () => loadAccountsForBootCard(agentSlug),
2797
+ probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentSlug, t),
2789
2798
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
2790
2799
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
2800
+ ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
2791
2801
  }, ackMsgId).then(handle => {
2792
2802
  activeBootCard = handle
2793
2803
  }).catch((err: Error) => {
@@ -3485,6 +3495,31 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
3485
3495
  }
3486
3496
  }
3487
3497
 
3498
+ // Last-resort: resend this chunk as plain text (parse_mode unset).
3499
+ // Keeps thread / reply / markup params; only the formatting is
3500
+ // sacrificed. Used when Telegram rejects our HTML — better an
3501
+ // unformatted answer than a vanished one.
3502
+ const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
3503
+ const plainOpts = { ...opts }
3504
+ delete (plainOpts as { parse_mode?: unknown }).parse_mode
3505
+ // A chunk that was pure markup (no text content) strips to ''.
3506
+ // Sending '' is a Telegram 400 "message text is empty" — i.e.
3507
+ // the answer would still vanish, in the exact path that exists
3508
+ // to prevent that. Substitute an honest placeholder so the
3509
+ // user at least sees that a fragment was unrenderable.
3510
+ const stripped = telegramHtmlToPlainText(chunks[i])
3511
+ const plain =
3512
+ stripped.length > 0
3513
+ ? stripped
3514
+ : '⚠️ (a formatted fragment could not be rendered for Telegram)'
3515
+ const sent = await lockedBot.api.sendMessage(chat_id, plain, plainOpts as never)
3516
+ sentIds.push(sent.message_id)
3517
+ logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
3518
+ process.stderr.write(
3519
+ `telegram gateway: HTML parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
3520
+ )
3521
+ }
3522
+
3488
3523
  try {
3489
3524
  const sent = await robustApiCall(
3490
3525
  () => lockedBot.api.sendMessage(chat_id, chunks[i], sendOpts as never),
@@ -3497,8 +3532,16 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
3497
3532
  threadId = undefined
3498
3533
  const retryOpts = { ...sendOpts }
3499
3534
  delete (retryOpts as any).message_thread_id
3500
- const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
3501
- sentIds.push(sent.message_id)
3535
+ try {
3536
+ const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
3537
+ sentIds.push(sent.message_id)
3538
+ } catch (retryErr) {
3539
+ // Thread dropped, but the HTML is also unparseable — go plain.
3540
+ if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
3541
+ else throw retryErr
3542
+ }
3543
+ } else if (isHtmlParseRejectError(err)) {
3544
+ await sendChunkPlainText(sendOpts)
3502
3545
  } else {
3503
3546
  throw err
3504
3547
  }
@@ -6659,7 +6702,7 @@ async function handleInbound(
6659
6702
  } else {
6660
6703
  // Fresh turn — priorTurnInFlight is false, so priorActive is
6661
6704
  // provably undefined. Earlier `if (priorActive)` block was dead
6662
- // code (mirrors first-paint.ts cleanup).
6705
+ // code, removed in the same first-paint cleanup pass.
6663
6706
  const sKey = streamKey(chat_id, messageThreadId)
6664
6707
  const priorStream = activeDraftStreams.get(sKey)
6665
6708
  if (priorStream && !priorStream.isFinal()) {
@@ -7771,26 +7814,65 @@ async function runSwitchroomCommandFormatted(ctx: Context, args: string[], label
7771
7814
  // every agent can self-restart without admin privilege. `/restart <other>`
7772
7815
  // is blocked just like any other admin verb.
7773
7816
  //
7774
- // Invariant: when AGENT_ADMIN=true, this middleware is a no-op — bot.command()
7775
- // handlers run normally for all admin verbs and Claude never sees them.
7817
+ // sec WS7-F2 (#1394): when AGENT_ADMIN=true this middleware is NO LONGER a
7818
+ // no-op a `block`-classified verb (fleet-admin / `/restart <other>`)
7819
+ // requires OPERATOR-PRIVATE (a private chat from a strict
7820
+ // `access.allowFrom` sender), because the per-command `isAuthorizedSender`
7821
+ // gate treats an empty group `allowFrom` as "allow every member". Non-
7822
+ // admin-verb traffic (`pass-through`, incl. `/restart`-self and all normal
7823
+ // chat) is untouched and reaches `next()` exactly as before.
7776
7824
  bot.use(async (ctx, next) => {
7777
- if (!AGENT_ADMIN && ctx.message?.text) {
7825
+ if (ctx.message?.text) {
7778
7826
  const myName = getMyAgentName()
7779
7827
  const decision = classifyAdminGate(ctx.message.text, myName)
7780
7828
  if (decision.action === 'block') {
7781
- // Block admin commands the LLM should never see. Reply with a concise
7782
- // "admin required" warning instead of forwarding to Claude.
7783
- process.stderr.write(
7784
- `telegram gateway: admin-gate blocked cmd=/${decision.cmd} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} reason=${decision.reason} (AGENT_ADMIN=false)\n`,
7785
- )
7829
+ // `block` = a fleet-admin verb (ADMIN_COMMAND_NAMES) or
7830
+ // `/restart <other-agent>`. classifyAdminGate already lets
7831
+ // `/restart`-self and every non-admin command pass through, so
7832
+ // this branch is exactly the privileged set.
7786
7833
  const cmdHtml = escapeHtmlForTg(`/${decision.cmd}`)
7787
7834
  const nameHtml = escapeHtmlForTg(myName)
7788
- const text =
7835
+ const notFlagged =
7789
7836
  decision.reason === 'other-agent'
7790
7837
  ? `⚠️ <code>${cmdHtml}</code> targeting another agent is an admin operation — this agent (<code>${nameHtml}</code>) isn't admin-flagged. Run it from an admin agent, or set <code>admin: true</code> for this agent in switchroom.yaml. (Self-restart is allowed: send <code>/restart</code> with no arg.)`
7791
7838
  : `⚠️ <code>${cmdHtml}</code> is an admin command — this agent (<code>${nameHtml}</code>) isn't admin-flagged. Run it from an admin agent, or set <code>admin: true</code> for this agent in switchroom.yaml.`
7792
- await switchroomReply(ctx, text, { html: true })
7793
- return
7839
+ if (!AGENT_ADMIN) {
7840
+ // Unchanged behaviour: a non-admin agent never executes admin
7841
+ // verbs locally and must not forward them to Claude.
7842
+ process.stderr.write(
7843
+ `telegram gateway: admin-gate blocked cmd=/${decision.cmd} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} reason=${decision.reason} (AGENT_ADMIN=false)\n`,
7844
+ )
7845
+ await switchroomReply(ctx, notFlagged, { html: true })
7846
+ return
7847
+ }
7848
+ // sec WS7-F2 (#1394): fleet-admin is OPERATOR-PRIVATE. Honor it
7849
+ // ONLY in a private chat from an `access.allowFrom` sender.
7850
+ // Before this, when AGENT_ADMIN=true the middleware was a no-op
7851
+ // and the per-command `isAuthorizedSender` gate treats an empty
7852
+ // group `allowFrom` as "allow every member" — so any member of
7853
+ // an admin agent's forum/group could run /vault, /update apply,
7854
+ // /grant, /dangerous, etc. (the default shape for an agent
7855
+ // created via `agent add --topology forum` + `admin: true`).
7856
+ // Strict `access.allowFrom` + private-chat-only — never the
7857
+ // group-permissive isAuthorizedSender.
7858
+ const senderId = String(ctx.from?.id ?? '')
7859
+ const operatorPrivate =
7860
+ ctx.chat?.type === 'private' &&
7861
+ loadAccess().allowFrom.includes(senderId)
7862
+ if (!operatorPrivate) {
7863
+ process.stderr.write(
7864
+ `telegram gateway: admin-gate refused (not operator-private) cmd=/${decision.cmd} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} chat=${ctx.chat?.type ?? '?'} sender=${senderId}\n`,
7865
+ )
7866
+ await switchroomReply(
7867
+ ctx,
7868
+ `⚠️ <code>${cmdHtml}</code> is a fleet-admin command — it is <b>operator-private</b>. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
7869
+ { html: true },
7870
+ )
7871
+ return
7872
+ }
7873
+ // operator-private admin verb on an admin agent → fall through
7874
+ // to the bot.command() handler (which re-checks isAuthorizedSender
7875
+ // — redundant but harmless in a private allowFrom chat).
7794
7876
  }
7795
7877
  }
7796
7878
  await next()
@@ -7959,6 +8041,7 @@ async function buildLiveProbeRows(agentName: string): Promise<StatusProbeRow[]>
7959
8041
  gatewayInfo: { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS },
7960
8042
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
7961
8043
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
8044
+ probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentName, t),
7962
8045
  })
7963
8046
  const rows: StatusProbeRow[] = []
7964
8047
  // Render order matches the boot card's PROBE_KEYS so the two
@@ -8575,7 +8658,7 @@ bot.command('audit', async ctx => {
8575
8658
  if (arg === '' || arg === 'help' || arg === '--help') {
8576
8659
  await switchroomReply(
8577
8660
  ctx,
8578
- 'Usage: <code>/audit hostd [--tail N] [--agent &lt;name&gt;] [--op &lt;verb&gt;] [--error]</code>',
8661
+ 'Usage: <code>/audit hostd [--tail N] [--agent &lt;name&gt;] [--op &lt;verb&gt;] [--error] [--verbose]</code>',
8579
8662
  { html: true },
8580
8663
  )
8581
8664
  return
@@ -8602,6 +8685,7 @@ bot.command('audit', async ctx => {
8602
8685
  for (let i = 1; i < tokens.length; i++) {
8603
8686
  const t = tokens[i]!
8604
8687
  if (t === '--error') { argv.push('--error'); continue }
8688
+ if (t === '--verbose') { argv.push('--verbose'); continue }
8605
8689
  if (t === '--tail' || t === '--agent' || t === '--op') {
8606
8690
  const v = tokens[++i]
8607
8691
  if (v == null) {
@@ -8631,7 +8715,7 @@ bot.command('audit', async ctx => {
8631
8715
  await switchroomReply(
8632
8716
  ctx,
8633
8717
  `Unknown flag <code>${escapeHtmlForTg(t)}</code>. ` +
8634
- `Allowed: <code>--tail</code>, <code>--agent</code>, <code>--op</code>, <code>--error</code>.`,
8718
+ `Allowed: <code>--tail</code>, <code>--agent</code>, <code>--op</code>, <code>--error</code>, <code>--verbose</code>.`,
8635
8719
  { html: true },
8636
8720
  )
8637
8721
  return
@@ -8908,12 +8992,18 @@ async function doFireFleetAutoFallback(triggerAgent: string): Promise<boolean> {
8908
8992
  return false
8909
8993
  }
8910
8994
  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
- )
8995
+ // Probe live quota via the broker (#1336). Pre-fix this read
8996
+ // credentials.json off the agent HOME, which is never populated
8997
+ // post-RFC-H every account looked "no credentials" and the
8998
+ // fallback logic rolled blindly. Broker-routed probes use the
8999
+ // canonical stored tokens.
9000
+ const probeResp = state.accounts.length > 0
9001
+ ? await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
9002
+ : { results: [] }
9003
+ const quotas = state.accounts.map((a) => {
9004
+ const hit = probeResp.results.find((r) => r.label === a.label)
9005
+ return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
9006
+ })
8917
9007
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
8918
9008
  const outcome = await runFleetAutoFallback({
8919
9009
  state,
@@ -9006,7 +9096,39 @@ async function runCreditWatch(): Promise<void> {
9006
9096
  }
9007
9097
 
9008
9098
  bot.command("auth", async ctx => {
9009
- if (!isAuthorizedSender(ctx)) return
9099
+ // sec WS7-F2b (#1394): `/auth` drives the auth-broker credential
9100
+ // lifecycle (`/auth add` mints/attaches an Anthropic account token,
9101
+ // `/auth use` switches the active account, …). It is NOT in
9102
+ // ADMIN_COMMAND_NAMES (deliberately gateway-handled even on
9103
+ // non-admin agents so it works when the model is unreachable — that
9104
+ // routing is unchanged), so the WS7-F2 operator-private middleware
9105
+ // does not cover it, and its only sender gate was the
9106
+ // group-permissive `isAuthorizedSender` (empty group `allowFrom` =
9107
+ // allow every member). On an `admin:true` forum agent any
9108
+ // forum/supergroup member could therefore run privileged `/auth`.
9109
+ // Credential-plane admin is OPERATOR-PRIVATE, exactly like the
9110
+ // ADMIN_COMMAND_NAMES verbs (WS7-F2 / #1408): honor `/auth` ONLY in
9111
+ // a private chat from a strict `access.allowFrom` sender — never the
9112
+ // group-permissive isAuthorizedSender. The agent-admin-flag /
9113
+ // broker-side enforcement (isAuthAdmin below) is orthogonal and
9114
+ // unchanged; operator auth-recovery is via DM (same as #1408).
9115
+ const authSenderId = String(ctx.from?.id ?? '')
9116
+ const authOperatorPrivate =
9117
+ ctx.chat?.type === 'private' &&
9118
+ loadAccess().allowFrom.includes(authSenderId)
9119
+ if (!authOperatorPrivate) {
9120
+ if (ctx.chat?.type !== 'private') {
9121
+ process.stderr.write(
9122
+ `telegram gateway: /auth refused (not operator-private) agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} chat=${ctx.chat?.type ?? '?'} sender=${authSenderId}\n`,
9123
+ )
9124
+ await switchroomReply(
9125
+ ctx,
9126
+ `⚠️ <code>/auth</code> manages account credentials — it is <b>operator-private</b>. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
9127
+ { html: true },
9128
+ ).catch(() => {})
9129
+ }
9130
+ return
9131
+ }
9010
9132
  const text = ctx.message?.text ?? ""
9011
9133
  const parsed = parseAuthCommand(text)
9012
9134
  if (!parsed) return
@@ -9098,17 +9220,31 @@ bot.command("auth", async ctx => {
9098
9220
  isAdmin,
9099
9221
  client,
9100
9222
  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
- ),
9223
+ // Format 2 enricher — live quota probe via the broker (#1336).
9224
+ // Pre-broker this read `~/.switchroom/accounts/<label>/credentials.json`
9225
+ // off the agent's HOME, which post-RFC-H is never populated (broker
9226
+ // writes only the per-agent .claude/.credentials.json mirror) so
9227
+ // every account showed "no credentials.json or accessToken" in
9228
+ // /auth show. The broker is the source of truth for tokens and now
9229
+ // does the Anthropic probe server-side via `probe-quota`. Tokens
9230
+ // never leave the broker container.
9231
+ liveQuotas: async (accounts) => {
9232
+ try {
9233
+ const { results } = await client.probeQuota(accounts.map((a) => a.label))
9234
+ // Preserve input order (broker also preserves it, but be defensive).
9235
+ return accounts.map((a) => {
9236
+ const hit = results.find((r) => r.label === a.label)
9237
+ if (!hit) return { ok: false as const, reason: "broker returned no result for account" }
9238
+ return hit.result
9239
+ })
9240
+ } catch (err) {
9241
+ // Surface a uniform per-account failure so the dashboard renders
9242
+ // gracefully (label badge stays UNKNOWN) instead of falling back
9243
+ // to the legacy table.
9244
+ const reason = `broker probe-quota failed: ${(err as Error)?.message ?? String(err)}`
9245
+ return accounts.map(() => ({ ok: false as const, reason }))
9246
+ }
9247
+ },
9112
9248
  tz: process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ,
9113
9249
  })
9114
9250
  // Translate the handler's optional keyboard shape into grammy's
@@ -9155,6 +9291,33 @@ async function loadAccountsForBootCard(agent: string): Promise<ListStateData | n
9155
9291
  }
9156
9292
  }
9157
9293
 
9294
+ /**
9295
+ * Canonical boot-card quota probe (#1336): resolve this agent's
9296
+ * effective account, then have the broker probe Anthropic server-side.
9297
+ * Returns null on any failure (broker unreachable, no active account)
9298
+ * so `probeQuota` falls back to a direct probe. Mirrors
9299
+ * `loadAccountsForBootCard`'s broker-client + swallow-to-null shape,
9300
+ * and the override→account→active resolution used by auth-line.ts.
9301
+ */
9302
+ async function probeQuotaForBootCard(
9303
+ agent: string,
9304
+ timeoutMs?: number,
9305
+ ): Promise<QuotaResult | null> {
9306
+ try {
9307
+ const client = await getAuthBrokerClient(agent)
9308
+ if (!client) return null
9309
+ const state = await client.listState()
9310
+ const entry = state.agents.find((a) => a.name === agent)
9311
+ const label = entry?.override ?? entry?.account ?? state.active
9312
+ if (!label) return null
9313
+ const { results } = await client.probeQuota([label], timeoutMs)
9314
+ return results.find((r) => r.label === label)?.result ?? null
9315
+ } catch (err) {
9316
+ process.stderr.write(`telegram gateway: boot-card quota probe failed: ${(err as Error)?.message ?? String(err)}\n`)
9317
+ return null
9318
+ }
9319
+ }
9320
+
9158
9321
  /**
9159
9322
  * Read the pending auth session's target slot from the agent's
9160
9323
  * `.setup-token.session.json` meta file. Returns null when no session
@@ -10855,9 +11018,14 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
10855
11018
  return
10856
11019
  }
10857
11020
  const state = await client.listState()
10858
- const quotas = await Promise.all(
10859
- state.accounts.map((a) => fetchAccountQuota(a.label, { force: true })),
10860
- )
11021
+ // Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
11022
+ const probeResp = state.accounts.length > 0
11023
+ ? await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
11024
+ : { results: [] }
11025
+ const quotas = state.accounts.map((a) => {
11026
+ const hit = probeResp.results.find((r) => r.label === a.label)
11027
+ return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
11028
+ })
10861
11029
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
10862
11030
  const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
10863
11031
  '../auth-snapshot-format.js'
@@ -11329,9 +11497,12 @@ bot.command('usage', async ctx => {
11329
11497
  if (client) {
11330
11498
  const state = await client.listState()
11331
11499
  if (state.accounts.length > 0) {
11332
- const quotas = await Promise.all(
11333
- state.accounts.map((a) => fetchAccountQuota(a.label, { force: true })),
11334
- )
11500
+ // Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
11501
+ const probeResp = await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
11502
+ const quotas = state.accounts.map((a) => {
11503
+ const hit = probeResp.results.find((r) => r.label === a.label)
11504
+ return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
11505
+ })
11335
11506
  const { renderAuthSnapshotFormat2, buildSnapshotsFromState } = await import(
11336
11507
  '../auth-snapshot-format.js'
11337
11508
  )
@@ -11474,7 +11645,24 @@ bot.on('callback_query:data', async ctx => {
11474
11645
  // Routed through the generic kernel handler so any surface that uses
11475
11646
  // buildApprovalCard inherits consume → record → confirmation UX without
11476
11647
  // each surface re-implementing it.
11648
+ //
11649
+ // SECURITY (sec WS7-F1, #1394): this is the human-in-the-loop gate for
11650
+ // EVERY dangerous tool call + Drive write. handleApprovalCallback records
11651
+ // whoever tapped as the approver (`granted_by_user_id = ctx.from.id`) and
11652
+ // the approval-kernel performs NO server-side approver validation, so an
11653
+ // unauthorized tap is laundered into a real grant. The card is delivered
11654
+ // to the agent's chat(s) — for a forum/group agent that is every member.
11655
+ // Refuse callbacks from anyone outside `access.allowFrom`, BEFORE the
11656
+ // approval_consume nonce burn. Strict `access.allowFrom` — identical to
11657
+ // the drvpick:/op:/vd:/vg:/vra:/vrs:/vrd: families; the absence of this
11658
+ // check (not a deliberate exemption) was the vulnerability.
11477
11659
  if (data.startsWith('apv:')) {
11660
+ const access = loadAccess()
11661
+ const senderId = String(ctx.from?.id ?? '')
11662
+ if (!access.allowFrom.includes(senderId)) {
11663
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
11664
+ return
11665
+ }
11478
11666
  const { handleApprovalCallback } = await import('./approval-callback.js')
11479
11667
  await handleApprovalCallback(ctx, data)
11480
11668
  return
@@ -11485,10 +11673,11 @@ bot.on('callback_query:data', async ctx => {
11485
11673
  // grant writes an allow_always kernel decision at
11486
11674
  // doc:gdrive:folder/<id>/** and edits the card to a confirmation.
11487
11675
  //
11488
- // Auth gate: the picker grant is an OPERATOR action (mirrors the
11489
- // `op:`/`vd:`/`vg:` family, not the `apv:` agent-approval shape).
11490
- // Mirror those patterns refuse callbacks from anyone outside
11491
- // `access.allowFrom`. Without this, a group member who isn't in
11676
+ // Auth gate: the picker grant is an OPERATOR action same strict
11677
+ // `access.allowFrom` check as every other callback family (`op:`/
11678
+ // `vd:`/`vg:`/`apv:` since sec WS7-F1, …). Refuse callbacks from
11679
+ // anyone outside `access.allowFrom`. Without this, a group member
11680
+ // who isn't in
11492
11681
  // the operator allowlist could still tap [✅ Allow "<folder>"] on
11493
11682
  // a card that landed in the group and write an `allow_always`
11494
11683
  // decision attributed to themselves.
@@ -13406,6 +13595,13 @@ void (async () => {
13406
13595
  // sendMessage round-trip) sees an in-flight emit. See #489.
13407
13596
  bootCardPending = true
13408
13597
  try {
13598
+ // PR C: mirror the bridge-reconnect path — surface
13599
+ // a recent terminal update_apply outcome with claim
13600
+ // dedupe so it doesn't render twice if bridge
13601
+ // re-connects within the lookback window.
13602
+ const updateOutcomeLine = (() => {
13603
+ try { return maybeRenderUpdateAnnouncement() ?? undefined } catch { return undefined }
13604
+ })()
13409
13605
  const handle = await startBootCard(chatId, threadId, botApiForCard, {
13410
13606
  agentName: agentDisplayName,
13411
13607
  agentSlug,
@@ -13416,8 +13612,10 @@ void (async () => {
13416
13612
  restartAgeMs: markerAgeMs,
13417
13613
  restartReasonDetail: cleanMarker?.reason,
13418
13614
  loadAccounts: () => loadAccountsForBootCard(agentSlug),
13615
+ probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentSlug, t),
13419
13616
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
13420
13617
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
13618
+ ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
13421
13619
  }, ackMsgId)
13422
13620
  activeBootCard = handle
13423
13621
  } catch (err) {
@@ -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
  }