switchroom 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +32 -16
  2. package/dist/agent-scheduler/index.js +216 -97
  3. package/dist/auth-broker/index.js +176 -97
  4. package/dist/cli/drive-write-pretool.mjs +26 -11
  5. package/dist/cli/skill-validate-pretool.mjs +7209 -0
  6. package/dist/cli/switchroom.js +45571 -42642
  7. package/dist/cli/ui/index.html +1281 -0
  8. package/dist/host-control/main.js +3628 -309
  9. package/dist/vault/approvals/kernel-server.js +207 -98
  10. package/dist/vault/broker/server.js +249 -119
  11. package/examples/personal-google-workspace-mcp/README.md +8 -3
  12. package/examples/switchroom.yaml +91 -42
  13. package/package.json +4 -3
  14. package/profiles/_base/start.sh.hbs +76 -36
  15. package/profiles/_shared/agent-self-service.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +4 -2
  17. package/skills/file-bug/SKILL.md +6 -4
  18. package/skills/skill-creator/SKILL.md +52 -0
  19. package/skills/switchroom-cli/SKILL.md +20 -4
  20. package/skills/switchroom-install/SKILL.md +3 -3
  21. package/telegram-plugin/auth-snapshot-format.ts +9 -9
  22. package/telegram-plugin/card-format.ts +3 -3
  23. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  24. package/telegram-plugin/dist/gateway/gateway.js +853 -414
  25. package/telegram-plugin/dist/server.js +162 -161
  26. package/telegram-plugin/format.ts +71 -0
  27. package/telegram-plugin/gateway/access-validator.test.ts +8 -8
  28. package/telegram-plugin/gateway/access-validator.ts +1 -1
  29. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  30. package/telegram-plugin/gateway/approval-card.ts +1 -1
  31. package/telegram-plugin/gateway/auth-command.ts +2 -2
  32. package/telegram-plugin/gateway/boot-card.ts +40 -3
  33. package/telegram-plugin/gateway/boot-probes.ts +114 -30
  34. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  35. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  36. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  37. package/telegram-plugin/gateway/gateway.ts +265 -22
  38. package/telegram-plugin/gateway/update-announce.ts +167 -0
  39. package/telegram-plugin/quota-check.ts +0 -195
  40. package/telegram-plugin/recent-outbound-dedup.ts +1 -1
  41. package/telegram-plugin/registry/turns-schema.ts +1 -1
  42. package/telegram-plugin/retry-api-call.ts +24 -0
  43. package/telegram-plugin/server.ts +8 -5
  44. package/telegram-plugin/tests/auth-add-flow.test.ts +32 -3
  45. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  46. package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
  48. package/telegram-plugin/tests/boot-probes.test.ts +90 -2
  49. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  50. package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
  51. package/telegram-plugin/tests/fleet-state.test.ts +3 -2
  52. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  53. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  54. package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
  55. package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
  56. package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
  57. package/telegram-plugin/tests/secret-detect.test.ts +8 -8
  58. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  59. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  60. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
  61. package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
  62. package/telegram-plugin/welcome-text.ts +1 -8
  63. package/profiles/default/CLAUDE.md +0 -192
  64. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  65. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  66. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  67. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  68. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  69. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  70. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  71. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  72. package/telegram-plugin/first-paint.ts +0 -225
  73. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  74. package/telegram-plugin/server.js +0 -41795
  75. package/telegram-plugin/tests/html-balanced.ts +0 -63
  76. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  77. package/telegram-plugin/tool-error-filter.ts +0 -89
@@ -41,8 +41,8 @@ describe("buildDiffPreviewCard — suggest mode (default)", () => {
41
41
  const preview = buildDiffPreview(baseInput({ agentSummary: "Added Hiring section" }));
42
42
  const card = buildDiffPreviewCard({
43
43
  preview,
44
- suggestRequestId: "aabbccdd",
45
- writeRequestId: "11223344",
44
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
45
+ writeRequestId: "11223344112233441122334411223344",
46
46
  });
47
47
 
48
48
  // Body: title bold + all preview lines.
@@ -59,19 +59,19 @@ describe("buildDiffPreviewCard — suggest mode (default)", () => {
59
59
  expect(r[0]?.[0]?.text).toBe("📖 Open in Drive");
60
60
  expect(r[0]?.[0]?.url).toBe("https://docs.google.com/document/d/DOC1/edit");
61
61
  expect(r[0]?.[1]?.text).toBe("✅ Apply as suggestion");
62
- expect(r[0]?.[1]?.callback_data).toBe("apv:aabbccdd:once");
62
+ expect(r[0]?.[1]?.callback_data).toBe("apv:aabbccddaabbccddaabbccddaabbccdd:once");
63
63
  // Row 2: [Apply directly] [Cancel]
64
64
  expect(r[1]?.[0]?.text).toBe("⚠ Apply directly");
65
- expect(r[1]?.[0]?.callback_data).toBe("apv:11223344:once");
65
+ expect(r[1]?.[0]?.callback_data).toBe("apv:11223344112233441122334411223344:once");
66
66
  expect(r[1]?.[1]?.text).toBe("🚫 Cancel");
67
- expect(r[1]?.[1]?.callback_data).toBe("apv:aabbccdd:deny");
67
+ expect(r[1]?.[1]?.callback_data).toBe("apv:aabbccddaabbccddaabbccddaabbccdd:deny");
68
68
  });
69
69
 
70
70
  it("hides 'Apply directly' when writeRequestId is undefined", () => {
71
71
  const preview = buildDiffPreview(baseInput());
72
72
  const card = buildDiffPreviewCard({
73
73
  preview,
74
- suggestRequestId: "aabbccdd",
74
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
75
75
  });
76
76
  const flat = rows(card.reply_markup).flat();
77
77
  expect(flat.find((b) => b.text === "⚠ Apply directly")).toBeUndefined();
@@ -91,8 +91,8 @@ describe("buildDiffPreviewCard — write mode (opt-in via expand)", () => {
91
91
  // callback's deny channel — semantically Cancel is "don't grant
92
92
  // either scope" but reusing the suggest id keeps the existing
93
93
  // approval-callback handler stateless.
94
- suggestRequestId: "aabbccdd",
95
- writeRequestId: "11223344",
94
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
95
+ writeRequestId: "11223344112233441122334411223344",
96
96
  });
97
97
 
98
98
  const r = rows(card.reply_markup);
@@ -100,7 +100,7 @@ describe("buildDiffPreviewCard — write mode (opt-in via expand)", () => {
100
100
  expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeUndefined();
101
101
  const directly = flat.find((b) => b.text === "⚠ Apply directly");
102
102
  expect(directly).toBeDefined();
103
- expect(directly?.callback_data).toBe("apv:11223344:once");
103
+ expect(directly?.callback_data).toBe("apv:11223344112233441122334411223344:once");
104
104
  // Title icon swaps to ⚠.
105
105
  expect(card.text).toContain("⚠");
106
106
  });
@@ -119,7 +119,7 @@ describe("buildDiffPreviewCard — input validation", () => {
119
119
  expect(() =>
120
120
  buildDiffPreviewCard({
121
121
  preview,
122
- suggestRequestId: "aabbccdd",
122
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
123
123
  writeRequestId: "ABCDEF01", // wrong case
124
124
  }),
125
125
  ).toThrow(/8 hex chars/);
@@ -136,7 +136,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
136
136
  );
137
137
  const card = buildDiffPreviewCard({
138
138
  preview,
139
- suggestRequestId: "aabbccdd",
139
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
140
140
  });
141
141
  const flat = rows(card.reply_markup).flat();
142
142
  expect(flat.find((b) => b.text === "📖 Open in Drive")).toBeUndefined();
@@ -150,7 +150,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
150
150
  );
151
151
  const card = buildDiffPreviewCard({
152
152
  preview,
153
- suggestRequestId: "aabbccdd",
153
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
154
154
  });
155
155
  expect(card.text).not.toContain("<script>");
156
156
  expect(card.text).toContain("&lt;script&gt;");
@@ -162,7 +162,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
162
162
  );
163
163
  const card = buildDiffPreviewCard({
164
164
  preview,
165
- suggestRequestId: "aabbccdd",
165
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
166
166
  });
167
167
  expect(card.text).not.toMatch(/💬.*<b>/);
168
168
  expect(card.text).toContain("&lt;b&gt;");
@@ -175,8 +175,8 @@ describe("buildDiffPreviewCard — audit fidelity", () => {
175
175
  const preview = buildDiffPreview(input);
176
176
  const card = buildDiffPreviewCard({
177
177
  preview,
178
- suggestRequestId: "aabbccdd",
179
- writeRequestId: "11223344",
178
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
179
+ writeRequestId: "11223344112233441122334411223344",
180
180
  });
181
181
  // The audit row captures both wrapper truth + agent framing,
182
182
  // exactly as surfaced on the card.
@@ -55,7 +55,7 @@ export interface DiffPreviewCardInput {
55
55
  * callback_data that the dispatcher rejects, but we'd rather fail
56
56
  * loudly at build time.
57
57
  */
58
- const REQUEST_ID_RE = /^[0-9a-f]{8}$/;
58
+ const REQUEST_ID_RE = /^[0-9a-f]{32}$/;
59
59
 
60
60
  /**
61
61
  * Fragility-guard from B2 review: the `create_doc` prep helper
@@ -66,7 +66,7 @@ function deps(overrides: Partial<DriveApprovalHandlerDeps> & { spy: Spy }): Driv
66
66
  ttl_ms: args.ttl_ms,
67
67
  approver_set: args.approver_set,
68
68
  });
69
- return { request_id: "aabbccdd", expires_at_ms: Date.now() + args.ttl_ms };
69
+ return { request_id: "aabbccddaabbccddaabbccddaabbccdd", expires_at_ms: Date.now() + args.ttl_ms };
70
70
  },
71
71
  postCard: async (args) => {
72
72
  spy.posted.push({
@@ -123,7 +123,7 @@ describe("handleRequestDriveApproval — happy path", () => {
123
123
  type: "drive_approval_posted",
124
124
  correlationId: "corr-1",
125
125
  ok: true,
126
- requestId: "aabbccdd",
126
+ requestId: "aabbccddaabbccddaabbccddaabbccdd",
127
127
  });
128
128
  expect(spy.sent[0]?.expiresAtMs).toBeGreaterThan(Date.now());
129
129
  });
@@ -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
  }
@@ -4460,6 +4504,44 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
4460
4504
 
4461
4505
  const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
4462
4506
 
4507
+ // Fix B (#1487 follow-up): if this agent's STANDING ACL already
4508
+ // covers the key, do NOT render a card or mint a grant. Minting
4509
+ // writes a `.vault-token` that — pre-#1487 — *shadowed* the standing
4510
+ // ACL (the exact gymbro trap) and is simply redundant post-#1487.
4511
+ // Determine coverage AUTHORITATIVELY by probing the broker AS THIS
4512
+ // AGENT (no-token list over the per-agent socket — path-as-identity;
4513
+ // the gateway runs in the agent's container so the broker attributes
4514
+ // it to this agent). NOT a gateway-side config read: the gateway can
4515
+ // see newer config than the broker has loaded, so a config-derived
4516
+ // "covered" could be wrong where the broker still denies. `list`
4517
+ // returns only ACL-visible key NAMES — never secret values. Read
4518
+ // scope only: schedule.secrets[] confers read, not write.
4519
+ if (scopeRaw === 'read') {
4520
+ try {
4521
+ const visible = await listViaBroker()
4522
+ if (visible !== null && visible.includes(key)) {
4523
+ return {
4524
+ content: [
4525
+ {
4526
+ type: 'text',
4527
+ text:
4528
+ `vault_request_access: '${key}' is ALREADY covered by ${agentSlug}'s ` +
4529
+ `standing ACL (schedule.secrets[]). No approval card or grant is needed — ` +
4530
+ `read it directly: \`switchroom vault get ${key}\`. Do NOT request a grant ` +
4531
+ `for this key (a minted token would shadow the standing ACL). If a read ` +
4532
+ `still returns VAULT-BROKER-DENIED, the broker likely needs a restart to ` +
4533
+ `pick up a recent config change — tell the operator; don't re-request.`,
4534
+ },
4535
+ ],
4536
+ }
4537
+ }
4538
+ } catch {
4539
+ // Probe failed (broker unreachable / transient): fall through to
4540
+ // the normal card flow. Fail-open is correct here — a redundant
4541
+ // card is harmless; suppressing a needed card is not.
4542
+ }
4543
+ }
4544
+
4463
4545
  const stageId = randomBytes(4).toString('hex')
4464
4546
  const pending: PendingVaultRequestAccess = {
4465
4547
  agent: agentSlug,
@@ -6658,7 +6740,7 @@ async function handleInbound(
6658
6740
  } else {
6659
6741
  // Fresh turn — priorTurnInFlight is false, so priorActive is
6660
6742
  // provably undefined. Earlier `if (priorActive)` block was dead
6661
- // code (mirrors first-paint.ts cleanup).
6743
+ // code, removed in the same first-paint cleanup pass.
6662
6744
  const sKey = streamKey(chat_id, messageThreadId)
6663
6745
  const priorStream = activeDraftStreams.get(sKey)
6664
6746
  if (priorStream && !priorStream.isFinal()) {
@@ -7770,26 +7852,65 @@ async function runSwitchroomCommandFormatted(ctx: Context, args: string[], label
7770
7852
  // every agent can self-restart without admin privilege. `/restart <other>`
7771
7853
  // is blocked just like any other admin verb.
7772
7854
  //
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.
7855
+ // sec WS7-F2 (#1394): when AGENT_ADMIN=true this middleware is NO LONGER a
7856
+ // no-op a `block`-classified verb (fleet-admin / `/restart <other>`)
7857
+ // requires OPERATOR-PRIVATE (a private chat from a strict
7858
+ // `access.allowFrom` sender), because the per-command `isAuthorizedSender`
7859
+ // gate treats an empty group `allowFrom` as "allow every member". Non-
7860
+ // admin-verb traffic (`pass-through`, incl. `/restart`-self and all normal
7861
+ // chat) is untouched and reaches `next()` exactly as before.
7775
7862
  bot.use(async (ctx, next) => {
7776
- if (!AGENT_ADMIN && ctx.message?.text) {
7863
+ if (ctx.message?.text) {
7777
7864
  const myName = getMyAgentName()
7778
7865
  const decision = classifyAdminGate(ctx.message.text, myName)
7779
7866
  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
- )
7867
+ // `block` = a fleet-admin verb (ADMIN_COMMAND_NAMES) or
7868
+ // `/restart <other-agent>`. classifyAdminGate already lets
7869
+ // `/restart`-self and every non-admin command pass through, so
7870
+ // this branch is exactly the privileged set.
7785
7871
  const cmdHtml = escapeHtmlForTg(`/${decision.cmd}`)
7786
7872
  const nameHtml = escapeHtmlForTg(myName)
7787
- const text =
7873
+ const notFlagged =
7788
7874
  decision.reason === 'other-agent'
7789
7875
  ? `⚠️ <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
7876
  : `⚠️ <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
7877
+ if (!AGENT_ADMIN) {
7878
+ // Unchanged behaviour: a non-admin agent never executes admin
7879
+ // verbs locally and must not forward them to Claude.
7880
+ process.stderr.write(
7881
+ `telegram gateway: admin-gate blocked cmd=/${decision.cmd} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} reason=${decision.reason} (AGENT_ADMIN=false)\n`,
7882
+ )
7883
+ await switchroomReply(ctx, notFlagged, { html: true })
7884
+ return
7885
+ }
7886
+ // sec WS7-F2 (#1394): fleet-admin is OPERATOR-PRIVATE. Honor it
7887
+ // ONLY in a private chat from an `access.allowFrom` sender.
7888
+ // Before this, when AGENT_ADMIN=true the middleware was a no-op
7889
+ // and the per-command `isAuthorizedSender` gate treats an empty
7890
+ // group `allowFrom` as "allow every member" — so any member of
7891
+ // an admin agent's forum/group could run /vault, /update apply,
7892
+ // /grant, /dangerous, etc. (the default shape for an agent
7893
+ // created via `agent add --topology forum` + `admin: true`).
7894
+ // Strict `access.allowFrom` + private-chat-only — never the
7895
+ // group-permissive isAuthorizedSender.
7896
+ const senderId = String(ctx.from?.id ?? '')
7897
+ const operatorPrivate =
7898
+ ctx.chat?.type === 'private' &&
7899
+ loadAccess().allowFrom.includes(senderId)
7900
+ if (!operatorPrivate) {
7901
+ process.stderr.write(
7902
+ `telegram gateway: admin-gate refused (not operator-private) cmd=/${decision.cmd} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} chat=${ctx.chat?.type ?? '?'} sender=${senderId}\n`,
7903
+ )
7904
+ await switchroomReply(
7905
+ ctx,
7906
+ `⚠️ <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.`,
7907
+ { html: true },
7908
+ )
7909
+ return
7910
+ }
7911
+ // operator-private admin verb on an admin agent → fall through
7912
+ // to the bot.command() handler (which re-checks isAuthorizedSender
7913
+ // — redundant but harmless in a private allowFrom chat).
7793
7914
  }
7794
7915
  }
7795
7916
  await next()
@@ -7958,6 +8079,7 @@ async function buildLiveProbeRows(agentName: string): Promise<StatusProbeRow[]>
7958
8079
  gatewayInfo: { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS },
7959
8080
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
7960
8081
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
8082
+ probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentName, t),
7961
8083
  })
7962
8084
  const rows: StatusProbeRow[] = []
7963
8085
  // Render order matches the boot card's PROBE_KEYS so the two
@@ -8574,7 +8696,7 @@ bot.command('audit', async ctx => {
8574
8696
  if (arg === '' || arg === 'help' || arg === '--help') {
8575
8697
  await switchroomReply(
8576
8698
  ctx,
8577
- 'Usage: <code>/audit hostd [--tail N] [--agent &lt;name&gt;] [--op &lt;verb&gt;] [--error]</code>',
8699
+ 'Usage: <code>/audit hostd [--tail N] [--agent &lt;name&gt;] [--op &lt;verb&gt;] [--error] [--verbose]</code>',
8578
8700
  { html: true },
8579
8701
  )
8580
8702
  return
@@ -8601,6 +8723,7 @@ bot.command('audit', async ctx => {
8601
8723
  for (let i = 1; i < tokens.length; i++) {
8602
8724
  const t = tokens[i]!
8603
8725
  if (t === '--error') { argv.push('--error'); continue }
8726
+ if (t === '--verbose') { argv.push('--verbose'); continue }
8604
8727
  if (t === '--tail' || t === '--agent' || t === '--op') {
8605
8728
  const v = tokens[++i]
8606
8729
  if (v == null) {
@@ -8630,7 +8753,7 @@ bot.command('audit', async ctx => {
8630
8753
  await switchroomReply(
8631
8754
  ctx,
8632
8755
  `Unknown flag <code>${escapeHtmlForTg(t)}</code>. ` +
8633
- `Allowed: <code>--tail</code>, <code>--agent</code>, <code>--op</code>, <code>--error</code>.`,
8756
+ `Allowed: <code>--tail</code>, <code>--agent</code>, <code>--op</code>, <code>--error</code>, <code>--verbose</code>.`,
8634
8757
  { html: true },
8635
8758
  )
8636
8759
  return
@@ -9011,7 +9134,39 @@ async function runCreditWatch(): Promise<void> {
9011
9134
  }
9012
9135
 
9013
9136
  bot.command("auth", async ctx => {
9014
- if (!isAuthorizedSender(ctx)) return
9137
+ // sec WS7-F2b (#1394): `/auth` drives the auth-broker credential
9138
+ // lifecycle (`/auth add` mints/attaches an Anthropic account token,
9139
+ // `/auth use` switches the active account, …). It is NOT in
9140
+ // ADMIN_COMMAND_NAMES (deliberately gateway-handled even on
9141
+ // non-admin agents so it works when the model is unreachable — that
9142
+ // routing is unchanged), so the WS7-F2 operator-private middleware
9143
+ // does not cover it, and its only sender gate was the
9144
+ // group-permissive `isAuthorizedSender` (empty group `allowFrom` =
9145
+ // allow every member). On an `admin:true` forum agent any
9146
+ // forum/supergroup member could therefore run privileged `/auth`.
9147
+ // Credential-plane admin is OPERATOR-PRIVATE, exactly like the
9148
+ // ADMIN_COMMAND_NAMES verbs (WS7-F2 / #1408): honor `/auth` ONLY in
9149
+ // a private chat from a strict `access.allowFrom` sender — never the
9150
+ // group-permissive isAuthorizedSender. The agent-admin-flag /
9151
+ // broker-side enforcement (isAuthAdmin below) is orthogonal and
9152
+ // unchanged; operator auth-recovery is via DM (same as #1408).
9153
+ const authSenderId = String(ctx.from?.id ?? '')
9154
+ const authOperatorPrivate =
9155
+ ctx.chat?.type === 'private' &&
9156
+ loadAccess().allowFrom.includes(authSenderId)
9157
+ if (!authOperatorPrivate) {
9158
+ if (ctx.chat?.type !== 'private') {
9159
+ process.stderr.write(
9160
+ `telegram gateway: /auth refused (not operator-private) agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} chat=${ctx.chat?.type ?? '?'} sender=${authSenderId}\n`,
9161
+ )
9162
+ await switchroomReply(
9163
+ ctx,
9164
+ `⚠️ <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.`,
9165
+ { html: true },
9166
+ ).catch(() => {})
9167
+ }
9168
+ return
9169
+ }
9015
9170
  const text = ctx.message?.text ?? ""
9016
9171
  const parsed = parseAuthCommand(text)
9017
9172
  if (!parsed) return
@@ -9174,6 +9329,33 @@ async function loadAccountsForBootCard(agent: string): Promise<ListStateData | n
9174
9329
  }
9175
9330
  }
9176
9331
 
9332
+ /**
9333
+ * Canonical boot-card quota probe (#1336): resolve this agent's
9334
+ * effective account, then have the broker probe Anthropic server-side.
9335
+ * Returns null on any failure (broker unreachable, no active account)
9336
+ * so `probeQuota` falls back to a direct probe. Mirrors
9337
+ * `loadAccountsForBootCard`'s broker-client + swallow-to-null shape,
9338
+ * and the override→account→active resolution used by auth-line.ts.
9339
+ */
9340
+ async function probeQuotaForBootCard(
9341
+ agent: string,
9342
+ timeoutMs?: number,
9343
+ ): Promise<QuotaResult | null> {
9344
+ try {
9345
+ const client = await getAuthBrokerClient(agent)
9346
+ if (!client) return null
9347
+ const state = await client.listState()
9348
+ const entry = state.agents.find((a) => a.name === agent)
9349
+ const label = entry?.override ?? entry?.account ?? state.active
9350
+ if (!label) return null
9351
+ const { results } = await client.probeQuota([label], timeoutMs)
9352
+ return results.find((r) => r.label === label)?.result ?? null
9353
+ } catch (err) {
9354
+ process.stderr.write(`telegram gateway: boot-card quota probe failed: ${(err as Error)?.message ?? String(err)}\n`)
9355
+ return null
9356
+ }
9357
+ }
9358
+
9177
9359
  /**
9178
9360
  * Read the pending auth session's target slot from the agent's
9179
9361
  * `.setup-token.session.json` meta file. Returns null when no session
@@ -9408,6 +9590,40 @@ async function performVaultAccessApproval(
9408
9590
  attestation.kind === 'passphrase'
9409
9591
  ? { passphrase: attestation.passphrase }
9410
9592
  : { attest_via_posture: true as const }
9593
+
9594
+ // Fix B (#1487 follow-up), operator-tap guard. Defense-in-depth for a
9595
+ // card staged before the key became standing-ACL-covered (config edit
9596
+ // / #1487 deploy / drift): if the agent's standing ACL ALREADY covers
9597
+ // this read key, do NOT mint — minting writes a `.vault-token` that
9598
+ // shadows the standing ACL and is redundant. Authoritative broker
9599
+ // probe AS THIS AGENT (no-token list over the per-agent socket — same
9600
+ // rationale as executeVaultRequestAccess; never a gateway-side config
9601
+ // read). Read scope only. Fail-open on probe error (mint as before).
9602
+ if (pending.scope === 'read') {
9603
+ try {
9604
+ const visible = await listViaBroker()
9605
+ if (visible !== null && visible.includes(pending.key)) {
9606
+ pendingVaultRequestAccesses.delete(stageId)
9607
+ if (pending.card_message_id != null) {
9608
+ await ctx.api
9609
+ .editMessageText(
9610
+ pending.chat_id,
9611
+ pending.card_message_id,
9612
+ `ℹ️ <b>${escapeHtmlForTg(pending.agent)}</b> already has standing-ACL access to ` +
9613
+ `<code>${escapeHtmlForTg(pending.key)}</code> (schedule.secrets[]). ` +
9614
+ `<b>No grant minted</b> — a token would shadow the standing ACL. ` +
9615
+ `The agent can read it directly.`,
9616
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
9617
+ )
9618
+ .catch(() => {})
9619
+ }
9620
+ return
9621
+ }
9622
+ } catch {
9623
+ // Probe failed: fall through and mint as before (fail-open).
9624
+ }
9625
+ }
9626
+
9411
9627
  // #1051: union the new key with the agent's existing active grant
9412
9628
  // before minting. Without this, each fresh Approve OVERWRITES the
9413
9629
  // agent's `.vault-token` file with a single-key grant — the
@@ -11501,7 +11717,24 @@ bot.on('callback_query:data', async ctx => {
11501
11717
  // Routed through the generic kernel handler so any surface that uses
11502
11718
  // buildApprovalCard inherits consume → record → confirmation UX without
11503
11719
  // each surface re-implementing it.
11720
+ //
11721
+ // SECURITY (sec WS7-F1, #1394): this is the human-in-the-loop gate for
11722
+ // EVERY dangerous tool call + Drive write. handleApprovalCallback records
11723
+ // whoever tapped as the approver (`granted_by_user_id = ctx.from.id`) and
11724
+ // the approval-kernel performs NO server-side approver validation, so an
11725
+ // unauthorized tap is laundered into a real grant. The card is delivered
11726
+ // to the agent's chat(s) — for a forum/group agent that is every member.
11727
+ // Refuse callbacks from anyone outside `access.allowFrom`, BEFORE the
11728
+ // approval_consume nonce burn. Strict `access.allowFrom` — identical to
11729
+ // the drvpick:/op:/vd:/vg:/vra:/vrs:/vrd: families; the absence of this
11730
+ // check (not a deliberate exemption) was the vulnerability.
11504
11731
  if (data.startsWith('apv:')) {
11732
+ const access = loadAccess()
11733
+ const senderId = String(ctx.from?.id ?? '')
11734
+ if (!access.allowFrom.includes(senderId)) {
11735
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
11736
+ return
11737
+ }
11505
11738
  const { handleApprovalCallback } = await import('./approval-callback.js')
11506
11739
  await handleApprovalCallback(ctx, data)
11507
11740
  return
@@ -11512,10 +11745,11 @@ bot.on('callback_query:data', async ctx => {
11512
11745
  // grant writes an allow_always kernel decision at
11513
11746
  // doc:gdrive:folder/<id>/** and edits the card to a confirmation.
11514
11747
  //
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
11748
+ // Auth gate: the picker grant is an OPERATOR action same strict
11749
+ // `access.allowFrom` check as every other callback family (`op:`/
11750
+ // `vd:`/`vg:`/`apv:` since sec WS7-F1, …). Refuse callbacks from
11751
+ // anyone outside `access.allowFrom`. Without this, a group member
11752
+ // who isn't in
11519
11753
  // the operator allowlist could still tap [✅ Allow "<folder>"] on
11520
11754
  // a card that landed in the group and write an `allow_always`
11521
11755
  // decision attributed to themselves.
@@ -13433,6 +13667,13 @@ void (async () => {
13433
13667
  // sendMessage round-trip) sees an in-flight emit. See #489.
13434
13668
  bootCardPending = true
13435
13669
  try {
13670
+ // PR C: mirror the bridge-reconnect path — surface
13671
+ // a recent terminal update_apply outcome with claim
13672
+ // dedupe so it doesn't render twice if bridge
13673
+ // re-connects within the lookback window.
13674
+ const updateOutcomeLine = (() => {
13675
+ try { return maybeRenderUpdateAnnouncement() ?? undefined } catch { return undefined }
13676
+ })()
13436
13677
  const handle = await startBootCard(chatId, threadId, botApiForCard, {
13437
13678
  agentName: agentDisplayName,
13438
13679
  agentSlug,
@@ -13443,8 +13684,10 @@ void (async () => {
13443
13684
  restartAgeMs: markerAgeMs,
13444
13685
  restartReasonDetail: cleanMarker?.reason,
13445
13686
  loadAccounts: () => loadAccountsForBootCard(agentSlug),
13687
+ probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentSlug, t),
13446
13688
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
13447
13689
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
13690
+ ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
13448
13691
  }, ackMsgId)
13449
13692
  activeBootCard = handle
13450
13693
  } catch (err) {