switchroom 0.18.8 → 0.18.9

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 (36) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +2 -2
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
  6. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  7. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  8. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  9. package/telegram-plugin/gateway/gateway.ts +527 -2880
  10. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  11. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  13. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  14. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  15. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  16. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  17. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  18. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  19. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  20. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  21. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  22. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  23. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  24. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  26. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  27. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  29. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  30. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  31. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  32. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  33. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  34. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  35. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  36. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
@@ -0,0 +1,99 @@
1
+ /**
2
+ * In-memory storage for the four AGENT-INITIATED approval-card families
3
+ * (vault_request_access / vault_request_save / request_secret /
4
+ * mental_model_propose), extracted from gateway.ts (Phase 3 step 1 of the
5
+ * gateway decomposition — see #2996). STORAGE ONLY: the per-card expiry LOGIC
6
+ * (card edits, timeout synthetics, missed-approval re-offers) still lives in
7
+ * gateway.ts and is injected here as `expire`. This module owns the Map
8
+ * lifecycle and CO-LOCATES the TTL sweep next to the storage it sweeps, so the
9
+ * gateway's authoritative `pendingStateReaper` calls a single `store.sweep(now)`
10
+ * delegation instead of open-coding a `sweepExpiredEntries(...)` per family.
11
+ *
12
+ * Why a Map-compatible surface (get/set/delete/has/iteration) rather than a
13
+ * bespoke API: every existing gateway call site — the callback-query handlers,
14
+ * the executor add-sites, and `restorePendingApprovalCards` — used the raw Map
15
+ * directly. Preserving the exact Map method surface keeps those call sites
16
+ * BYTE-IDENTICAL (the variable name and every `.get`/`.set`/`.delete`/`for..of`
17
+ * are unchanged), which is the invariant this phase must not break: the store
18
+ * moves WHERE the Map is constructed and WHERE its sweep lives, nothing about
19
+ * how the gateway reads or writes it. Restore semantics are therefore untouched.
20
+ *
21
+ * Race contract preserved (pinned by approval-card-stores.test.ts):
22
+ * - A verdict (operator tap → `.delete(stageId)`) arriving DURING an expiry
23
+ * sweep is single-shot-safe: the pure `expirePendingCard` core removes the
24
+ * entry before any fallible side effect, so the same card can never both
25
+ * expire and resolve. The store's `sweep` is a thin pass-through to that
26
+ * core via `sweepExpiredEntries`, so the ordering guarantee is unchanged.
27
+ * - A verdict arriving DURING restore is a plain `.set`/`.delete` on the same
28
+ * Map instance the restore populates — no separate storage to desync.
29
+ * - The shared reaper cadence is unchanged: `sweep(now)` performs exactly the
30
+ * work the old `sweepPendingX(now)` free functions did, in the same order.
31
+ *
32
+ * `expire` and `log` are supplied as THUNKS so a store constructed early in
33
+ * gateway module-eval can reference the hoisted `expire*Card` functions and the
34
+ * `const cardExpiryLog` arrow (which is in the temporal dead zone at
35
+ * construction time); both are resolved lazily at sweep time.
36
+ */
37
+
38
+ import { sweepExpiredEntries } from './pending-card-expiry.js'
39
+
40
+ export interface SweepableCardStore<T> {
41
+ get(stageId: string): T | undefined
42
+ set(stageId: string, value: T): void
43
+ delete(stageId: string): boolean
44
+ has(stageId: string): boolean
45
+ clear(): void
46
+ readonly size: number
47
+ keys(): IterableIterator<string>
48
+ values(): IterableIterator<T>
49
+ entries(): IterableIterator<[string, T]>
50
+ forEach(cb: (value: T, key: string, map: Map<string, T>) => void): void
51
+ [Symbol.iterator](): IterableIterator<[string, T]>
52
+ /**
53
+ * Expire every entry past its TTL. Delegates to the same pure
54
+ * `sweepExpiredEntries` core the gateway used inline, so per-entry
55
+ * fault-isolation and single-shot ordering are byte-identical.
56
+ */
57
+ sweep(now: number): void
58
+ }
59
+
60
+ export interface SweepableCardStoreDeps<T> {
61
+ /** Predicate: is this entry past its TTL at `now`? Closes over the family TTL. */
62
+ isExpired: (value: T, now: number) => boolean
63
+ /**
64
+ * Per-entry expiry action (card strip + timeout synthetic + missed-approval
65
+ * re-offer). A THUNK so it can reference gateway functions that are hoisted /
66
+ * defined after this store is constructed. Called once per expired entry.
67
+ */
68
+ expire: () => (stageId: string, value: T, now: number) => void
69
+ /** Error sink thunk (resolves the gateway's `cardExpiryLog` lazily). */
70
+ log: () => (msg: string) => void
71
+ }
72
+
73
+ /**
74
+ * Create a Map-backed, self-sweeping store for one approval-card family.
75
+ * The backing Map is the sole storage; the returned object forwards the Map
76
+ * surface the gateway uses plus a co-located `sweep`.
77
+ */
78
+ export function createSweepableCardStore<T>(
79
+ deps: SweepableCardStoreDeps<T>,
80
+ ): SweepableCardStore<T> {
81
+ const map = new Map<string, T>()
82
+ return {
83
+ get: (stageId) => map.get(stageId),
84
+ set: (stageId, value) => void map.set(stageId, value),
85
+ delete: (stageId) => map.delete(stageId),
86
+ has: (stageId) => map.has(stageId),
87
+ clear: () => map.clear(),
88
+ get size() {
89
+ return map.size
90
+ },
91
+ keys: () => map.keys(),
92
+ values: () => map.values(),
93
+ entries: () => map.entries(),
94
+ forEach: (cb) => map.forEach(cb),
95
+ [Symbol.iterator]: () => map[Symbol.iterator](),
96
+ sweep: (now) =>
97
+ sweepExpiredEntries(map, deps.isExpired, deps.expire(), now, deps.log()),
98
+ }
99
+ }
@@ -0,0 +1,194 @@
1
+ import type { Bot, Context, InlineKeyboard } from 'grammy'
2
+ import { hostdWillBeUsed } from './hostd-dispatch.js'
3
+ import { maskVaultKey } from '../demo-mask.js'
4
+ import { switchroomHelpText as buildSwitchroomHelpText } from '../welcome-text.js'
5
+
6
+ /**
7
+ * Read-only ops/info slash commands extracted verbatim from gateway.ts
8
+ * (#2996 Phase 5 leaf move): `/doctor`, `/grant`, `/dangerous`,
9
+ * `/permissions`, `/version`, `/whoami`, `/commands`.
10
+ *
11
+ * These are pure info/ops surfaces (no send-path or turn-lifecycle
12
+ * coupling). Every gateway-local helper they close over is passed in via
13
+ * `deps` (repo factory-deps precedent) so the module never back-references
14
+ * the gateway singleton or reads `currentTurn`. `bot` is injected so
15
+ * grammy registration order is preserved at the original call site.
16
+ */
17
+ export interface OpsInfoCommandDeps {
18
+ AGENT_ADMIN: boolean
19
+ isAuthorizedSender: (ctx: Context) => boolean
20
+ getMyAgentName: () => string
21
+ switchroomReply: (
22
+ ctx: Context,
23
+ text: string,
24
+ options?: {
25
+ html?: boolean
26
+ reply_markup?: InlineKeyboard
27
+ classification?: 'query' | 'mutation' | 'heavy'
28
+ },
29
+ ) => Promise<void>
30
+ buildDoctorScopeKeyboard: () => InlineKeyboard
31
+ renderSelfDoctor: (ctx: Context) => Promise<void>
32
+ preBlock: (text: string) => string
33
+ formatSwitchroomOutput: (output: string, maxLen?: number) => string
34
+ getCommandArgs: (ctx: Context) => string
35
+ assertSafeAgentName: (name: string) => void
36
+ runSwitchroomCommand: (
37
+ ctx: Context,
38
+ args: string[],
39
+ label: string,
40
+ classification?: 'query' | 'mutation' | 'heavy',
41
+ ) => Promise<void>
42
+ switchroomExecCombined: (args: string[], timeoutMs?: number) => string
43
+ stripAnsi: (text: string) => string
44
+ hasDemoFlag: (args: string) => boolean
45
+ escapeHtmlForTg: (text: string) => string
46
+ }
47
+
48
+ export function registerOpsInfoCommands(bot: Bot, deps: OpsInfoCommandDeps): void {
49
+ const {
50
+ AGENT_ADMIN,
51
+ isAuthorizedSender,
52
+ getMyAgentName,
53
+ switchroomReply,
54
+ buildDoctorScopeKeyboard,
55
+ renderSelfDoctor,
56
+ preBlock,
57
+ formatSwitchroomOutput,
58
+ getCommandArgs,
59
+ assertSafeAgentName,
60
+ runSwitchroomCommand,
61
+ switchroomExecCombined,
62
+ stripAnsi,
63
+ hasDemoFlag,
64
+ escapeHtmlForTg,
65
+ } = deps
66
+
67
+ /** Compact HTML card from the `config whoami` JSON view. Names/booleans only.
68
+ * `demo` (the `/whoami demo` suffix) masks the vault key NAMES via maskVaultKey
69
+ * for screen recordings — agent/MCP/model/skills topology is left untouched
70
+ * (out of scope). Off by default. */
71
+ function formatWhoamiCard(v: {
72
+ name?: string; persona?: string | null; model?: string | null; tier?: string;
73
+ tools?: { allow?: string[]; deny?: string[] }; mcpServers?: string[]; skills?: string[];
74
+ vault?: { key: string; readable: boolean }[];
75
+ powers?: { admin?: boolean; root?: boolean; configEdit?: boolean; crossAgentHostVerbs?: boolean };
76
+ scheduleCount?: number; memoryBackend?: string | null;
77
+ }, demo = false): string {
78
+ const esc = escapeHtmlForTg
79
+ const yn = (b?: boolean) => (b ? '✓' : '✗')
80
+ const lines: string[] = []
81
+ lines.push(`👤 **${esc(v.name ?? '?')}** · ${esc(v.tier ?? 'standard')}`)
82
+ if (v.persona) lines.push(esc(v.persona))
83
+ if (v.model) lines.push(`Model: ${esc(v.model)}`)
84
+ const allow = v.tools?.allow ?? []
85
+ lines.push(`Tools: ${allow.length ? esc(allow.slice(0, 8).join(', ')) + (allow.length > 8 ? ` …(+${allow.length - 8})` : '') : '—'}`)
86
+ if ((v.tools?.deny ?? []).length) lines.push(`Denied: ${esc((v.tools!.deny!).join(', '))}`)
87
+ if ((v.mcpServers ?? []).length) lines.push(`MCP: ${esc(v.mcpServers!.join(', '))}`)
88
+ if ((v.skills ?? []).length) lines.push(`Skills: ${esc(v.skills!.join(', '))}`)
89
+ if ((v.vault ?? []).length) {
90
+ lines.push(`Vault keys (names only): ${v.vault!.map(k => `${esc(demo ? maskVaultKey(k.key) : k.key)} ${yn(k.readable)}`).join(', ')}`)
91
+ }
92
+ const p = v.powers ?? {}
93
+ lines.push(`Powers: admin ${yn(p.admin)} · root ${yn(p.root)} · config-edit ${yn(p.configEdit)} · cross-agent verbs ${yn(p.crossAgentHostVerbs)}`)
94
+ lines.push(`Schedule: ${v.scheduleCount ?? 0} cron · Memory: ${esc(v.memoryBackend ?? 'none')}`)
95
+ return lines.join('\n')
96
+ }
97
+
98
+ bot.command('doctor', async ctx => {
99
+ if (!isAuthorizedSender(ctx)) return
100
+ try {
101
+ // Admin agents with hostd reachable choose scope (one tap, no
102
+ // approval card — doctor is read-only). Everyone else keeps the
103
+ // original zero-extra-tap in-container behaviour.
104
+ if (AGENT_ADMIN && hostdWillBeUsed(getMyAgentName())) {
105
+ await switchroomReply(ctx, '🩺 **Doctor** — which scope?', {
106
+ html: true,
107
+ reply_markup: buildDoctorScopeKeyboard(),
108
+ })
109
+ return
110
+ }
111
+ await renderSelfDoctor(ctx)
112
+ } catch (err: unknown) {
113
+ await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
114
+ }
115
+ })
116
+
117
+ bot.command('grant', async ctx => {
118
+ if (!isAuthorizedSender(ctx)) return
119
+ const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
120
+ if (parts.length === 0) { await switchroomReply(ctx, 'Usage: /grant <tool> or /grant <agent> <tool>'); return }
121
+ let agentName: string; let tool: string
122
+ if (parts.length === 1) { agentName = getMyAgentName(); tool = parts[0] }
123
+ else { agentName = parts[0]; tool = parts.slice(1).join(' ') }
124
+ try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
125
+ await runSwitchroomCommand(ctx, ['agent', 'grant', agentName, tool], `grant ${agentName} ${tool}`)
126
+ })
127
+
128
+ bot.command('dangerous', async ctx => {
129
+ if (!isAuthorizedSender(ctx)) return
130
+ const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
131
+ let agentName: string; let off = false
132
+ if (parts.length === 0) { agentName = getMyAgentName() }
133
+ else if (parts.length === 1 && parts[0] === 'off') { agentName = getMyAgentName(); off = true }
134
+ else { agentName = parts[0]; if (parts[1] === 'off') off = true }
135
+ try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
136
+ const args = ['agent', 'dangerous', agentName]; if (off) args.push('--off')
137
+ await runSwitchroomCommand(ctx, args, `dangerous ${agentName}${off ? ' off' : ''}`)
138
+ })
139
+
140
+ bot.command('permissions', async ctx => {
141
+ if (!isAuthorizedSender(ctx)) return
142
+ const agentName = (typeof ctx.match === "string" ? ctx.match : "").trim() || getMyAgentName()
143
+ try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
144
+ await runSwitchroomCommand(ctx, ['agent', 'permissions', agentName], `permissions ${agentName}`)
145
+ })
146
+
147
+ // Drive-by cleanup (#927): the dead /update handler that lived here
148
+ // was a pre-#919 stub. Grammy registers in order so the comprehensive
149
+ // /update handler at line ~6516 (added in #919, hardened in #924,
150
+ // docker-guarded in #934) fired first and this one never ran.
151
+ // Removed to avoid future confusion.
152
+
153
+ bot.command('version', async ctx => {
154
+ if (!isAuthorizedSender(ctx)) return
155
+ try {
156
+ let output: string
157
+ try { output = switchroomExecCombined(['version'], 10000) }
158
+ catch (err: unknown) { output = (err as any).stdout ?? (err as any).message ?? 'version failed' }
159
+ const trimmed = stripAnsi(output).trim()
160
+ if (!trimmed) { await switchroomReply(ctx, 'version: no output'); return }
161
+ await switchroomReply(ctx, preBlock(formatSwitchroomOutput(trimmed)), { html: true })
162
+ } catch (err: unknown) {
163
+ await switchroomReply(ctx, `**version failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
164
+ }
165
+ })
166
+
167
+
168
+ // /whoami — the operator's view of THIS agent's sandbox (the same
169
+ // `config whoami` the agent itself can call as an MCP tool, and the host CLI
170
+ // exposes). Read-only, isAuthorizedSender-gated like /version — surfaces
171
+ // tools / MCP / vault key-NAMES (never values) / powers so the operator can
172
+ // see at a glance what this agent is authorized for.
173
+ bot.command('whoami', async ctx => {
174
+ if (!isAuthorizedSender(ctx)) return
175
+ const demo = hasDemoFlag(getCommandArgs(ctx))
176
+ try {
177
+ let raw: string
178
+ try { raw = switchroomExecCombined(['config', 'whoami'], 10000) }
179
+ catch (err: unknown) { raw = (err as any).stdout ?? (err as any).message ?? 'whoami failed' }
180
+ const trimmed = stripAnsi(raw).trim()
181
+ let card: string
182
+ try { card = formatWhoamiCard(JSON.parse(trimmed.split('\n').pop() ?? trimmed), demo) }
183
+ catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
184
+ await switchroomReply(ctx, card, { html: true })
185
+ } catch (err: unknown) {
186
+ await switchroomReply(ctx, `**whoami failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
187
+ }
188
+ })
189
+
190
+ bot.command('commands', async ctx => {
191
+ if (!isAuthorizedSender(ctx)) return
192
+ await switchroomReply(ctx, buildSwitchroomHelpText(getMyAgentName()), { html: true })
193
+ })
194
+ }