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.
- package/README.md +7 -6
- package/dist/agent-scheduler/index.js +218 -99
- package/dist/auth-broker/index.js +300 -99
- package/dist/cli/drive-write-pretool.mjs +45 -12
- package/dist/cli/switchroom.js +44972 -42457
- package/dist/cli/ui/index.html +1281 -0
- package/dist/host-control/main.js +3630 -311
- package/dist/vault/approvals/kernel-server.js +209 -100
- package/dist/vault/broker/server.js +220 -99
- package/examples/personal-google-workspace-mcp/README.md +8 -3
- package/examples/switchroom.yaml +91 -42
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +76 -36
- package/profiles/default/CLAUDE.md.hbs +4 -2
- package/skills/file-bug/SKILL.md +6 -4
- package/skills/switchroom-cli/SKILL.md +20 -4
- package/skills/switchroom-install/SKILL.md +3 -3
- package/telegram-plugin/auth-snapshot-format.ts +4 -4
- package/telegram-plugin/auto-fallback-fleet.ts +4 -4
- package/telegram-plugin/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1029 -628
- package/telegram-plugin/dist/server.js +162 -161
- package/telegram-plugin/format.ts +71 -0
- package/telegram-plugin/gateway/approval-card.test.ts +18 -18
- package/telegram-plugin/gateway/approval-card.ts +1 -1
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
- package/telegram-plugin/gateway/auth-command.ts +12 -2
- package/telegram-plugin/gateway/boot-card.ts +40 -3
- package/telegram-plugin/gateway/boot-probes.ts +71 -27
- package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
- package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
- package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
- package/telegram-plugin/gateway/gateway.ts +244 -46
- package/telegram-plugin/gateway/hostd-dispatch.ts +10 -2
- package/telegram-plugin/gateway/update-announce.ts +167 -0
- package/telegram-plugin/quota-check.ts +0 -195
- package/telegram-plugin/retry-api-call.ts +24 -0
- package/telegram-plugin/server.ts +8 -5
- package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
- package/telegram-plugin/tests/boot-probes.test.ts +53 -0
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
- package/telegram-plugin/tests/quota-check.test.ts +0 -409
- package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
- package/telegram-plugin/tests/telegram-format.test.ts +84 -1
- package/telegram-plugin/tests/update-announce.test.ts +154 -0
- package/telegram-plugin/welcome-text.ts +1 -8
- package/profiles/default/CLAUDE.md +0 -192
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/telegram-plugin/first-paint.ts +0 -225
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/telegram-plugin/server.js +0 -41795
- package/telegram-plugin/tests/html-balanced.ts +0 -63
- package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
- package/telegram-plugin/tool-error-filter.ts +0 -89
|
@@ -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
|
-
|
|
3501
|
-
|
|
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
|
|
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
|
-
//
|
|
7775
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
7782
|
-
//
|
|
7783
|
-
|
|
7784
|
-
|
|
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
|
|
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
|
-
|
|
7793
|
-
|
|
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 <name>] [--op <verb>] [--error]</code>',
|
|
8661
|
+
'Usage: <code>/audit hostd [--tail N] [--agent <name>] [--op <verb>] [--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
|
|
8912
|
-
//
|
|
8913
|
-
//
|
|
8914
|
-
|
|
8915
|
-
|
|
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
|
-
|
|
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 —
|
|
9102
|
-
//
|
|
9103
|
-
//
|
|
9104
|
-
//
|
|
9105
|
-
//
|
|
9106
|
-
//
|
|
9107
|
-
//
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
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
|
-
|
|
10859
|
-
|
|
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
|
-
|
|
11333
|
-
|
|
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
|
|
11489
|
-
// `
|
|
11490
|
-
//
|
|
11491
|
-
// `access.allowFrom`. Without this, a group member
|
|
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
|
|
49
|
+
_hostdEnabled = cfg.host_control?.enabled !== false;
|
|
42
50
|
} catch {
|
|
43
51
|
_hostdEnabled = false;
|
|
44
52
|
}
|