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.
- package/README.md +7 -6
- package/dist/agent-scheduler/index.js +216 -97
- package/dist/auth-broker/index.js +175 -96
- package/dist/cli/drive-write-pretool.mjs +26 -11
- package/dist/cli/switchroom.js +45153 -42663
- package/dist/cli/ui/index.html +1281 -0
- package/dist/host-control/main.js +3628 -309
- package/dist/vault/approvals/kernel-server.js +207 -98
- package/dist/vault/broker/server.js +218 -97
- 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/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +795 -410
- 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-command.ts +2 -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 +193 -22
- 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'
|
|
@@ -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
|
-
|
|
3500
|
-
|
|
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
|
|
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
|
-
//
|
|
7774
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
7781
|
-
//
|
|
7782
|
-
|
|
7783
|
-
|
|
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
|
|
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
|
-
|
|
7792
|
-
|
|
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 <name>] [--op <verb>] [--error]</code>',
|
|
8661
|
+
'Usage: <code>/audit hostd [--tail N] [--agent <name>] [--op <verb>] [--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
|
-
|
|
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
|
|
11516
|
-
// `
|
|
11517
|
-
//
|
|
11518
|
-
// `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
|
|
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
|
+
}
|