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.
Files changed (59) hide show
  1. package/README.md +7 -6
  2. package/dist/agent-scheduler/index.js +216 -97
  3. package/dist/auth-broker/index.js +175 -96
  4. package/dist/cli/drive-write-pretool.mjs +26 -11
  5. package/dist/cli/switchroom.js +45153 -42663
  6. package/dist/cli/ui/index.html +1281 -0
  7. package/dist/host-control/main.js +3628 -309
  8. package/dist/vault/approvals/kernel-server.js +207 -98
  9. package/dist/vault/broker/server.js +218 -97
  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/card-format.ts +3 -3
  20. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  21. package/telegram-plugin/dist/gateway/gateway.js +795 -410
  22. package/telegram-plugin/dist/server.js +162 -161
  23. package/telegram-plugin/format.ts +71 -0
  24. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  25. package/telegram-plugin/gateway/approval-card.ts +1 -1
  26. package/telegram-plugin/gateway/auth-command.ts +2 -2
  27. package/telegram-plugin/gateway/boot-card.ts +40 -3
  28. package/telegram-plugin/gateway/boot-probes.ts +71 -27
  29. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  30. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  31. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  32. package/telegram-plugin/gateway/gateway.ts +193 -22
  33. package/telegram-plugin/gateway/update-announce.ts +167 -0
  34. package/telegram-plugin/quota-check.ts +0 -195
  35. package/telegram-plugin/retry-api-call.ts +24 -0
  36. package/telegram-plugin/server.ts +8 -5
  37. package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
  38. package/telegram-plugin/tests/boot-probes.test.ts +53 -0
  39. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  40. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  41. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  42. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  43. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  44. package/telegram-plugin/welcome-text.ts +1 -8
  45. package/profiles/default/CLAUDE.md +0 -192
  46. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  48. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  49. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  50. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  51. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  52. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  53. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  54. package/telegram-plugin/first-paint.ts +0 -225
  55. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  56. package/telegram-plugin/server.js +0 -41795
  57. package/telegram-plugin/tests/html-balanced.ts +0 -63
  58. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  59. 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'
@@ -137,7 +138,7 @@ import { validateStringArray } from './access-validator.js'
137
138
  * identical envelope shapes.
138
139
  */
139
140
  const REPLY_TO_TEXT_MAX = 200
140
- import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace } from '../format.js'
141
+ import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
141
142
  import {
142
143
  validateInlineKeyboard,
143
144
  type AnyButton,
@@ -213,6 +214,7 @@ import {
213
214
  import {
214
215
  fetchQuota,
215
216
  formatQuotaBlock,
217
+ type QuotaResult,
216
218
  } from '../quota-check.js'
217
219
  import {
218
220
  loadLockout,
@@ -301,6 +303,7 @@ import {
301
303
  } from './boot-card.js'
302
304
  import { determineRestartReason } from './boot-reason.js'
303
305
  import { shouldSkipDuplicateBootCard, type RestartReason } from './boot-card.js'
306
+ import { maybeRenderUpdateAnnouncement } from './update-announce.js'
304
307
  import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
305
308
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
306
309
  import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
@@ -2775,6 +2778,12 @@ const ipcServer: IpcServer = createIpcServer({
2775
2778
  // second bridge-reconnect in the same lifetime can't race against
2776
2779
  // an in-flight sendMessage here either (#489).
2777
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
+ })()
2778
2787
  startBootCard(chatId, threadId, botApiForCard, {
2779
2788
  agentName: agentDisplayName,
2780
2789
  agentSlug,
@@ -2785,8 +2794,10 @@ const ipcServer: IpcServer = createIpcServer({
2785
2794
  restartAgeMs: markerAgeMs,
2786
2795
  restartReasonDetail: cleanMarker?.reason,
2787
2796
  loadAccounts: () => loadAccountsForBootCard(agentSlug),
2797
+ probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentSlug, t),
2788
2798
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
2789
2799
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
2800
+ ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
2790
2801
  }, ackMsgId).then(handle => {
2791
2802
  activeBootCard = handle
2792
2803
  }).catch((err: Error) => {
@@ -3484,6 +3495,31 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
3484
3495
  }
3485
3496
  }
3486
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
+
3487
3523
  try {
3488
3524
  const sent = await robustApiCall(
3489
3525
  () => lockedBot.api.sendMessage(chat_id, chunks[i], sendOpts as never),
@@ -3496,8 +3532,16 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
3496
3532
  threadId = undefined
3497
3533
  const retryOpts = { ...sendOpts }
3498
3534
  delete (retryOpts as any).message_thread_id
3499
- const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
3500
- 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)
3501
3545
  } else {
3502
3546
  throw err
3503
3547
  }
@@ -6658,7 +6702,7 @@ async function handleInbound(
6658
6702
  } else {
6659
6703
  // Fresh turn — priorTurnInFlight is false, so priorActive is
6660
6704
  // provably undefined. Earlier `if (priorActive)` block was dead
6661
- // code (mirrors first-paint.ts cleanup).
6705
+ // code, removed in the same first-paint cleanup pass.
6662
6706
  const sKey = streamKey(chat_id, messageThreadId)
6663
6707
  const priorStream = activeDraftStreams.get(sKey)
6664
6708
  if (priorStream && !priorStream.isFinal()) {
@@ -7770,26 +7814,65 @@ async function runSwitchroomCommandFormatted(ctx: Context, args: string[], label
7770
7814
  // every agent can self-restart without admin privilege. `/restart <other>`
7771
7815
  // is blocked just like any other admin verb.
7772
7816
  //
7773
- // Invariant: when AGENT_ADMIN=true, this middleware is a no-op — bot.command()
7774
- // 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.
7775
7824
  bot.use(async (ctx, next) => {
7776
- if (!AGENT_ADMIN && ctx.message?.text) {
7825
+ if (ctx.message?.text) {
7777
7826
  const myName = getMyAgentName()
7778
7827
  const decision = classifyAdminGate(ctx.message.text, myName)
7779
7828
  if (decision.action === 'block') {
7780
- // Block admin commands the LLM should never see. Reply with a concise
7781
- // "admin required" warning instead of forwarding to Claude.
7782
- process.stderr.write(
7783
- `telegram gateway: admin-gate blocked cmd=/${decision.cmd} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} reason=${decision.reason} (AGENT_ADMIN=false)\n`,
7784
- )
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.
7785
7833
  const cmdHtml = escapeHtmlForTg(`/${decision.cmd}`)
7786
7834
  const nameHtml = escapeHtmlForTg(myName)
7787
- const text =
7835
+ const notFlagged =
7788
7836
  decision.reason === 'other-agent'
7789
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.)`
7790
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.`
7791
- await switchroomReply(ctx, text, { html: true })
7792
- 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).
7793
7876
  }
7794
7877
  }
7795
7878
  await next()
@@ -7958,6 +8041,7 @@ async function buildLiveProbeRows(agentName: string): Promise<StatusProbeRow[]>
7958
8041
  gatewayInfo: { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS },
7959
8042
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
7960
8043
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
8044
+ probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentName, t),
7961
8045
  })
7962
8046
  const rows: StatusProbeRow[] = []
7963
8047
  // Render order matches the boot card's PROBE_KEYS so the two
@@ -8574,7 +8658,7 @@ bot.command('audit', async ctx => {
8574
8658
  if (arg === '' || arg === 'help' || arg === '--help') {
8575
8659
  await switchroomReply(
8576
8660
  ctx,
8577
- '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>',
8578
8662
  { html: true },
8579
8663
  )
8580
8664
  return
@@ -8601,6 +8685,7 @@ bot.command('audit', async ctx => {
8601
8685
  for (let i = 1; i < tokens.length; i++) {
8602
8686
  const t = tokens[i]!
8603
8687
  if (t === '--error') { argv.push('--error'); continue }
8688
+ if (t === '--verbose') { argv.push('--verbose'); continue }
8604
8689
  if (t === '--tail' || t === '--agent' || t === '--op') {
8605
8690
  const v = tokens[++i]
8606
8691
  if (v == null) {
@@ -8630,7 +8715,7 @@ bot.command('audit', async ctx => {
8630
8715
  await switchroomReply(
8631
8716
  ctx,
8632
8717
  `Unknown flag <code>${escapeHtmlForTg(t)}</code>. ` +
8633
- `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>.`,
8634
8719
  { html: true },
8635
8720
  )
8636
8721
  return
@@ -9011,7 +9096,39 @@ async function runCreditWatch(): Promise<void> {
9011
9096
  }
9012
9097
 
9013
9098
  bot.command("auth", async ctx => {
9014
- 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
+ }
9015
9132
  const text = ctx.message?.text ?? ""
9016
9133
  const parsed = parseAuthCommand(text)
9017
9134
  if (!parsed) return
@@ -9174,6 +9291,33 @@ async function loadAccountsForBootCard(agent: string): Promise<ListStateData | n
9174
9291
  }
9175
9292
  }
9176
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
+
9177
9321
  /**
9178
9322
  * Read the pending auth session's target slot from the agent's
9179
9323
  * `.setup-token.session.json` meta file. Returns null when no session
@@ -11501,7 +11645,24 @@ bot.on('callback_query:data', async ctx => {
11501
11645
  // Routed through the generic kernel handler so any surface that uses
11502
11646
  // buildApprovalCard inherits consume → record → confirmation UX without
11503
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.
11504
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
+ }
11505
11666
  const { handleApprovalCallback } = await import('./approval-callback.js')
11506
11667
  await handleApprovalCallback(ctx, data)
11507
11668
  return
@@ -11512,10 +11673,11 @@ bot.on('callback_query:data', async ctx => {
11512
11673
  // grant writes an allow_always kernel decision at
11513
11674
  // doc:gdrive:folder/<id>/** and edits the card to a confirmation.
11514
11675
  //
11515
- // Auth gate: the picker grant is an OPERATOR action (mirrors the
11516
- // `op:`/`vd:`/`vg:` family, not the `apv:` agent-approval shape).
11517
- // Mirror those patterns refuse callbacks from anyone outside
11518
- // `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
11519
11681
  // the operator allowlist could still tap [✅ Allow "<folder>"] on
11520
11682
  // a card that landed in the group and write an `allow_always`
11521
11683
  // decision attributed to themselves.
@@ -13433,6 +13595,13 @@ void (async () => {
13433
13595
  // sendMessage round-trip) sees an in-flight emit. See #489.
13434
13596
  bootCardPending = true
13435
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
+ })()
13436
13605
  const handle = await startBootCard(chatId, threadId, botApiForCard, {
13437
13606
  agentName: agentDisplayName,
13438
13607
  agentSlug,
@@ -13443,8 +13612,10 @@ void (async () => {
13443
13612
  restartAgeMs: markerAgeMs,
13444
13613
  restartReasonDetail: cleanMarker?.reason,
13445
13614
  loadAccounts: () => loadAccountsForBootCard(agentSlug),
13615
+ probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentSlug, t),
13446
13616
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
13447
13617
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
13618
+ ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
13448
13619
  }, ackMsgId)
13449
13620
  activeBootCard = handle
13450
13621
  } catch (err) {
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Update-flow PR C — boot-card surfacing for MCP-originated updates.
3
+ *
4
+ * When an agent (e.g. klanker) runs `mcp__hostd__update_apply` the work
5
+ * happens hostd-side, the agent itself restarts at the end, and the
6
+ * resulting boot card for THIS agent (the one that just restarted via
7
+ * the redeploy) needs to surface the outcome — success or failure with a
8
+ * recovery hint — so the operator sees what happened without trawling
9
+ * the audit log.
10
+ *
11
+ * Implementation: on boot, scan ~/.switchroom/host-control-audit.log for
12
+ * the most recent `phase: "terminal"` `update_apply` row within a recent
13
+ * window. Dedupe via an atomic O_EXCL marker so a respawn within the
14
+ * window doesn't re-announce. Render a single line that's appended to
15
+ * the existing boot-card body.
16
+ *
17
+ * Pure & test-friendly — `readLastTerminalUpdateAudit` accepts an
18
+ * injectable file-reader, `renderUpdateOutcomeLine` is a pure function,
19
+ * and `claimUpdateAnnouncement` accepts an injectable claim-dir + clock.
20
+ */
21
+
22
+ import { existsSync, mkdirSync, openSync, closeSync, readFileSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import { homedir } from 'node:os'
25
+ import { readAndFilter, defaultAuditLogPath, type AuditEntry } from '../../src/host-control/audit-reader.js'
26
+
27
+ /** Default lookback window: 10 minutes is enough to catch the boot that
28
+ * follows a normal update_apply but small enough that an audit row from
29
+ * yesterday's run can't re-trigger an announcement after a long
30
+ * outage. */
31
+ export const DEFAULT_LOOKBACK_MS = 10 * 60 * 1000
32
+
33
+ export interface ReadOpts {
34
+ /** Read the file system. Override in tests. */
35
+ readFile?: (path: string) => string
36
+ /** Exists check. Override in tests. */
37
+ exists?: (path: string) => boolean
38
+ /** Override the audit-log path (defaults to ~/.switchroom/host-control-audit.log). */
39
+ auditLogPath?: string
40
+ /** Wall-clock for the lookback comparison. */
41
+ now?: number
42
+ /** Lookback window in ms. Defaults to {@link DEFAULT_LOOKBACK_MS}. */
43
+ lookbackMs?: number
44
+ }
45
+
46
+ /**
47
+ * Read the audit log and return the most-recent `update_apply` terminal
48
+ * row within the lookback window, or null if none. We deliberately do
49
+ * not filter by caller — any update_apply outcome is interesting to the
50
+ * person watching this chat regardless of which agent ran the verb.
51
+ */
52
+ export function readLastTerminalUpdateAudit(opts: ReadOpts = {}): AuditEntry | null {
53
+ const path = opts.auditLogPath ?? defaultAuditLogPath()
54
+ const exists = opts.exists ?? existsSync
55
+ const readFile = opts.readFile ?? ((p: string) => readFileSync(p, 'utf-8'))
56
+ if (!exists(path)) return null
57
+ let raw: string
58
+ try {
59
+ raw = readFile(path)
60
+ } catch {
61
+ return null
62
+ }
63
+ // Pull recent update_apply rows, then trim to terminal-phase within window.
64
+ const recent = readAndFilter(raw, { op: 'update_apply' }, 200)
65
+ const now = opts.now ?? Date.now()
66
+ const since = now - (opts.lookbackMs ?? DEFAULT_LOOKBACK_MS)
67
+ let best: AuditEntry | null = null
68
+ for (const e of recent) {
69
+ if (e.phase !== 'terminal') continue
70
+ const ts = Date.parse(e.ts)
71
+ if (Number.isNaN(ts)) continue
72
+ if (ts < since) continue
73
+ if (best == null || Date.parse(best.ts) < ts) best = e
74
+ }
75
+ return best
76
+ }
77
+
78
+ const RECOVERY_HINTS: Record<string, string> = {
79
+ binary:
80
+ 'curl https://switchroom.ai/install.sh | sh && switchroom update',
81
+ source:
82
+ 'cd ~/code/switchroom && git pull && bun install && bun run build && switchroom update',
83
+ 'source-unlinked':
84
+ 'cd ~/code/switchroom && bun link && switchroom update # ensures binary is in PATH first',
85
+ docker:
86
+ 'docker compose -p switchroom pull && docker compose -p switchroom up -d',
87
+ unknown:
88
+ 'Cannot auto-detect install type. Run `switchroom apply` to refresh ~/.switchroom/install-type.json, then retry.',
89
+ }
90
+
91
+ function recoveryHint(installType: string | undefined): string {
92
+ if (!installType) return RECOVERY_HINTS.unknown
93
+ return RECOVERY_HINTS[installType] ?? RECOVERY_HINTS.unknown
94
+ }
95
+
96
+ function shortSha(s: string): string {
97
+ return s.replace(/^sha256:/, '').slice(0, 12)
98
+ }
99
+
100
+ /**
101
+ * Pure renderer. Returns the single line (HTML-safe — plain ASCII)
102
+ * to append to the boot card body. `null` means nothing to surface
103
+ * (entry too stale, schema invalid, etc.).
104
+ */
105
+ export function renderUpdateOutcomeLine(entry: AuditEntry): string {
106
+ const success = entry.exit_code === 0 && entry.result !== 'error' && entry.result !== 'denied'
107
+ if (success) {
108
+ const channel = entry.channel ? `channel:${entry.channel}` : entry.pin ? `pin:${entry.pin}` : 'channel:?'
109
+ let shaStr = ''
110
+ if (entry.resolved_sha) {
111
+ const firstSha = Object.values(entry.resolved_sha)[0]
112
+ if (firstSha) shaStr = `, sha:${shortSha(firstSha)}`
113
+ }
114
+ return `✅ update completed (${channel}${shaStr})`
115
+ }
116
+ const stderrTail = (entry.stderr_tail ?? entry.error ?? '').slice(-400)
117
+ const opStep = entry.op
118
+ const hint = recoveryHint(entry.install_context?.install_type)
119
+ // Single line is reader-friendly when short; multi-line when stderr is present.
120
+ const lines = [`❌ update failed at ${opStep}: ${stderrTail || '(no stderr captured)'}`, ` ↳ Recovery: ${hint}`]
121
+ return lines.join('\n')
122
+ }
123
+
124
+ export interface ClaimOpts {
125
+ /** Override state-dir base (default: $TELEGRAM_STATE_DIR or ~/.switchroom/<agent>/telegram). */
126
+ stateDir?: string
127
+ }
128
+
129
+ /**
130
+ * Atomic claim via `O_CREAT|O_EXCL` — returns true if THIS process is
131
+ * the first to announce this request_id. Idempotent across respawns
132
+ * within the same state-dir. We deliberately don't clean up old
133
+ * markers — they're tiny and bounded by the lookback window above.
134
+ */
135
+ export function claimUpdateAnnouncement(requestId: string, opts: ClaimOpts = {}): boolean {
136
+ const stateDir = opts.stateDir ?? process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.switchroom')
137
+ const dir = join(stateDir, 'update-announced')
138
+ try {
139
+ mkdirSync(dir, { recursive: true })
140
+ } catch {
141
+ return false
142
+ }
143
+ const safeId = requestId.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 200)
144
+ const path = join(dir, safeId)
145
+ try {
146
+ // O_CREAT | O_EXCL — fails with EEXIST if another boot already
147
+ // claimed this request_id.
148
+ const fd = openSync(path, 'wx')
149
+ closeSync(fd)
150
+ return true
151
+ } catch {
152
+ return false
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Combined entry-point used by the gateway boot path: read + claim +
158
+ * render. Returns the line to append (already escaped for HTML — plain
159
+ * ASCII text — caller embeds with no further processing) or null when
160
+ * there's nothing to surface OR another boot already claimed the row.
161
+ */
162
+ export function maybeRenderUpdateAnnouncement(opts: ReadOpts & ClaimOpts = {}): string | null {
163
+ const entry = readLastTerminalUpdateAudit(opts)
164
+ if (!entry) return null
165
+ if (!claimUpdateAnnouncement(entry.request_id, opts)) return null
166
+ return renderUpdateOutcomeLine(entry)
167
+ }