switchroom 0.7.13 → 0.7.15
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 +4 -4
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/cli/switchroom.js +1881 -2878
- package/dist/vault/approvals/kernel-server.js +88 -85
- package/dist/vault/broker/server.js +495 -1785
- package/package.json +2 -4
- package/telegram-plugin/bridge/bridge.ts +17 -0
- package/telegram-plugin/dist/bridge/bridge.js +128 -112
- package/telegram-plugin/dist/foreman/foreman.js +185 -1696
- package/telegram-plugin/dist/gateway/gateway.js +542 -1682
- package/telegram-plugin/dist/server.js +176 -160
- package/telegram-plugin/gateway/gateway.ts +495 -7
- package/telegram-plugin/secret-detect/vault-error.test.ts +134 -0
- package/telegram-plugin/secret-detect/vault-error.ts +202 -0
- 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/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/telegram-plugin/server.js +0 -41795
|
@@ -258,6 +258,7 @@ import { runPipeline } from '../secret-detect/pipeline.js'
|
|
|
258
258
|
import { StagingMap } from '../secret-detect/staging.js'
|
|
259
259
|
import { maskToken } from '../secret-detect/mask.js'
|
|
260
260
|
import { defaultVaultWrite, defaultVaultList } from '../secret-detect/vault-write.js'
|
|
261
|
+
import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
|
|
261
262
|
import { detectSecrets } from '../secret-detect/index.js'
|
|
262
263
|
import { classifyAdminGate } from '../admin-commands/index.js'
|
|
263
264
|
import {
|
|
@@ -1390,6 +1391,9 @@ type PendingVaultOp =
|
|
|
1390
1391
|
}
|
|
1391
1392
|
// Issue #228: waiting for confirmation before revoking a grant.
|
|
1392
1393
|
| { kind: 'revoke_confirm'; grantId: string; agent: string; keys: string[]; startedAt: number }
|
|
1394
|
+
// Issue #969 P1a: user tapped "Rename" on a vault_request_save card;
|
|
1395
|
+
// the next message becomes the new key name for the staged save.
|
|
1396
|
+
| { kind: 'rename-vault-save'; stageId: string; startedAt: number }
|
|
1393
1397
|
const VAULT_INPUT_TTL_MS = 5 * 60 * 1000
|
|
1394
1398
|
const pendingVaultOps = new Map<string, PendingVaultOp>()
|
|
1395
1399
|
|
|
@@ -1426,6 +1430,40 @@ interface DeferredSecret {
|
|
|
1426
1430
|
}
|
|
1427
1431
|
const deferredSecrets = new Map<string, DeferredSecret>()
|
|
1428
1432
|
|
|
1433
|
+
/**
|
|
1434
|
+
* Agent-initiated save staging (issue #969 P1a). When an agent calls the
|
|
1435
|
+
* `vault_request_save` MCP tool, we stage the value here, render an
|
|
1436
|
+
* approval card to the user, and write to vault only on tap. The value
|
|
1437
|
+
* is held in gateway memory ONLY — never echoed back to the agent and
|
|
1438
|
+
* never logged.
|
|
1439
|
+
*/
|
|
1440
|
+
interface PendingVaultRequestSave {
|
|
1441
|
+
/** Agent that requested the save (process.env.SWITCHROOM_AGENT_NAME). */
|
|
1442
|
+
agent: string
|
|
1443
|
+
/** Chat to edit when the user taps. */
|
|
1444
|
+
chat_id: string
|
|
1445
|
+
/** Card message id (filled in after we send the card). */
|
|
1446
|
+
card_message_id?: number
|
|
1447
|
+
/** Currently-suggested slug; may be renamed by the user. */
|
|
1448
|
+
key: string
|
|
1449
|
+
/** Storage shape — 'string' (default) or 'binary'. */
|
|
1450
|
+
kind: 'string' | 'binary'
|
|
1451
|
+
/** The secret value, held in memory until the user approves/discards. */
|
|
1452
|
+
value: string
|
|
1453
|
+
/** Optional rationale shown on the card. */
|
|
1454
|
+
why?: string
|
|
1455
|
+
/** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
|
|
1456
|
+
staged_at: number
|
|
1457
|
+
}
|
|
1458
|
+
const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
|
|
1459
|
+
const VAULT_REQUEST_SAVE_TTL_MS = 10 * 60 * 1000
|
|
1460
|
+
function sweepPendingVaultRequestSaves(): void {
|
|
1461
|
+
const cutoff = Date.now() - VAULT_REQUEST_SAVE_TTL_MS
|
|
1462
|
+
for (const [k, v] of pendingVaultRequestSaves) {
|
|
1463
|
+
if (v.staged_at < cutoff) pendingVaultRequestSaves.delete(k)
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1429
1467
|
/**
|
|
1430
1468
|
* Mint an approval-kernel decision row for a deferred-secret card
|
|
1431
1469
|
* (MIGRATION.md §1). Best-effort: if the kernel/broker is unreachable, we
|
|
@@ -2373,6 +2411,7 @@ const ALLOWED_TOOLS = new Set([
|
|
|
2373
2411
|
'send_checklist', 'update_checklist',
|
|
2374
2412
|
'ask_user',
|
|
2375
2413
|
'send_sticker', 'send_gif',
|
|
2414
|
+
'vault_request_save',
|
|
2376
2415
|
])
|
|
2377
2416
|
|
|
2378
2417
|
async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
@@ -2412,6 +2451,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
|
|
|
2412
2451
|
return executeSendSticker(args)
|
|
2413
2452
|
case 'send_gif':
|
|
2414
2453
|
return executeSendGif(args)
|
|
2454
|
+
case 'vault_request_save':
|
|
2455
|
+
return executeVaultRequestSave(args)
|
|
2415
2456
|
default:
|
|
2416
2457
|
throw new Error(`unknown tool: ${tool}`)
|
|
2417
2458
|
}
|
|
@@ -3387,6 +3428,105 @@ async function publishToTelegraph(
|
|
|
3387
3428
|
return page.value.url
|
|
3388
3429
|
}
|
|
3389
3430
|
|
|
3431
|
+
/**
|
|
3432
|
+
* Build the inline keyboard for the agent-initiated vault-save approval card.
|
|
3433
|
+
* Issue #969 P1a. Callback prefix `vrs:` (vault-request-save).
|
|
3434
|
+
*
|
|
3435
|
+
* Buttons must fit Telegram's 64-byte callback_data limit. Stage IDs are
|
|
3436
|
+
* 8 hex chars (32 bits of entropy), so each callback comfortably fits.
|
|
3437
|
+
*/
|
|
3438
|
+
function buildVaultRequestSaveKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
|
|
3439
|
+
return {
|
|
3440
|
+
inline_keyboard: [
|
|
3441
|
+
[
|
|
3442
|
+
{ text: '✅ Save once', callback_data: `vrs:save:${stageId}` },
|
|
3443
|
+
{ text: '🚫 Discard', callback_data: `vrs:discard:${stageId}` },
|
|
3444
|
+
],
|
|
3445
|
+
[
|
|
3446
|
+
{ text: '✏️ Rename', callback_data: `vrs:rename:${stageId}` },
|
|
3447
|
+
],
|
|
3448
|
+
],
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
function renderVaultRequestSaveCard(req: PendingVaultRequestSave, agentSlug: string): string {
|
|
3453
|
+
const lines: string[] = []
|
|
3454
|
+
lines.push(`🔐 <b>${escapeHtmlForTg(agentSlug)}</b> wants to save a secret`)
|
|
3455
|
+
lines.push(`key: <code>${escapeHtmlForTg(req.key)}</code>`)
|
|
3456
|
+
if (req.why && req.why.length > 0) {
|
|
3457
|
+
lines.push(`why: <i>${escapeHtmlForTg(req.why)}</i>`)
|
|
3458
|
+
}
|
|
3459
|
+
lines.push('')
|
|
3460
|
+
lines.push(`<i>Tap Save to write to the host vault, Rename to change the key name, or Discard to drop it. The value is held in this chat's gateway memory until you decide.</i>`)
|
|
3461
|
+
return lines.join('\n')
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
/**
|
|
3465
|
+
* `vault_request_save` tool — agent surfaces an approval card asking
|
|
3466
|
+
* the user to confirm saving a secret. The value is staged here and
|
|
3467
|
+
* written to vault only on user tap. See #969 P1a.
|
|
3468
|
+
*/
|
|
3469
|
+
async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
3470
|
+
const chat_id = args.chat_id as string
|
|
3471
|
+
if (!chat_id) throw new Error('vault_request_save: chat_id is required')
|
|
3472
|
+
const key = args.key as string
|
|
3473
|
+
if (!key || typeof key !== 'string') throw new Error('vault_request_save: key is required')
|
|
3474
|
+
const value = args.value as string
|
|
3475
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
3476
|
+
throw new Error('vault_request_save: value is required and must be a non-empty string')
|
|
3477
|
+
}
|
|
3478
|
+
const why = typeof args.why === 'string' ? args.why : undefined
|
|
3479
|
+
const kindRaw = typeof args.kind === 'string' ? args.kind : 'string'
|
|
3480
|
+
if (kindRaw !== 'string' && kindRaw !== 'binary') {
|
|
3481
|
+
throw new Error('vault_request_save: kind must be "string" or "binary"')
|
|
3482
|
+
}
|
|
3483
|
+
assertAllowedChat(chat_id)
|
|
3484
|
+
|
|
3485
|
+
// Validate slug shape — vault keys must match a tight charset so the
|
|
3486
|
+
// host CLI hints render cleanly and reference resolution stays
|
|
3487
|
+
// predictable. Same shape as `validateVaultKey` in the broker, kept
|
|
3488
|
+
// local here to avoid pulling in the broker import.
|
|
3489
|
+
if (!/^[A-Za-z0-9_.-]{1,200}$/.test(key)) {
|
|
3490
|
+
throw new Error('vault_request_save: key must match [A-Za-z0-9_.-]{1,200}')
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
|
|
3494
|
+
|
|
3495
|
+
// Stage the request server-side. The value never leaves gateway memory
|
|
3496
|
+
// until the user approves.
|
|
3497
|
+
const stageId = randomBytes(4).toString('hex')
|
|
3498
|
+
const pending: PendingVaultRequestSave = {
|
|
3499
|
+
agent: agentSlug,
|
|
3500
|
+
chat_id,
|
|
3501
|
+
key,
|
|
3502
|
+
kind: kindRaw,
|
|
3503
|
+
value,
|
|
3504
|
+
why,
|
|
3505
|
+
staged_at: Date.now(),
|
|
3506
|
+
}
|
|
3507
|
+
pendingVaultRequestSaves.set(stageId, pending)
|
|
3508
|
+
sweepPendingVaultRequestSaves()
|
|
3509
|
+
|
|
3510
|
+
// Send the approval card.
|
|
3511
|
+
const text = renderVaultRequestSaveCard(pending, agentSlug)
|
|
3512
|
+
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
3513
|
+
const sent = await lockedBot.api.sendMessage(chat_id, text, {
|
|
3514
|
+
parse_mode: 'HTML',
|
|
3515
|
+
reply_markup: buildVaultRequestSaveKeyboard(stageId),
|
|
3516
|
+
...(threadId != null && Number.isFinite(threadId) ? { message_thread_id: threadId } : {}),
|
|
3517
|
+
})
|
|
3518
|
+
pending.card_message_id = sent.message_id
|
|
3519
|
+
|
|
3520
|
+
return {
|
|
3521
|
+
content: [
|
|
3522
|
+
{
|
|
3523
|
+
type: 'text',
|
|
3524
|
+
text: `vault_request_save: card sent (stage_id=${stageId}, key=${key}). The user must tap a button before the secret is persisted; do not assume success until you see the user's next message confirming the outcome.`,
|
|
3525
|
+
},
|
|
3526
|
+
],
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3390
3530
|
async function executeReact(args: Record<string, unknown>): Promise<unknown> {
|
|
3391
3531
|
if (!args.chat_id) throw new Error('react: chat_id is required')
|
|
3392
3532
|
if (!args.message_id) throw new Error('react: message_id is required')
|
|
@@ -4835,6 +4975,35 @@ async function handleInbound(
|
|
|
4835
4975
|
if (codeBlockMatch) value = codeBlockMatch[1]!
|
|
4836
4976
|
if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'vault secret value')
|
|
4837
4977
|
await executeVaultOp(ctx, chat_id, 'set', pendingVault.key, pendingVault.passphrase, value.trim())
|
|
4978
|
+
} else if (pendingVault.kind === 'rename-vault-save') {
|
|
4979
|
+
// Issue #969 P1a: user tapped Rename on a vault_request_save
|
|
4980
|
+
// card and is now telling us the new key name. Validate the
|
|
4981
|
+
// slug, update the staged entry, refresh the card.
|
|
4982
|
+
const newKey = text.trim()
|
|
4983
|
+
const staged = pendingVaultRequestSaves.get(pendingVault.stageId)
|
|
4984
|
+
if (!staged) {
|
|
4985
|
+
await switchroomReply(ctx, '⌛ That save card expired before you renamed. Ask the agent to re-issue.', { html: true })
|
|
4986
|
+
return
|
|
4987
|
+
}
|
|
4988
|
+
if (!/^[A-Za-z0-9_.-]{1,200}$/.test(newKey)) {
|
|
4989
|
+
// Re-arm the pending state so the user can try again.
|
|
4990
|
+
pendingVaultOps.set(chat_id, { ...pendingVault, startedAt: Date.now() })
|
|
4991
|
+
await switchroomReply(ctx, '⚠️ Key must match <code>[A-Za-z0-9_.-]</code> and be ≤ 200 chars. Send a different name.', { html: true })
|
|
4992
|
+
return
|
|
4993
|
+
}
|
|
4994
|
+
staged.key = newKey
|
|
4995
|
+
if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'vault rename input')
|
|
4996
|
+
// Edit the card in place with the new suggested key + same buttons.
|
|
4997
|
+
if (staged.card_message_id != null) {
|
|
4998
|
+
await ctx.api
|
|
4999
|
+
.editMessageText(
|
|
5000
|
+
staged.chat_id,
|
|
5001
|
+
staged.card_message_id,
|
|
5002
|
+
renderVaultRequestSaveCard(staged, staged.agent),
|
|
5003
|
+
{ parse_mode: 'HTML', reply_markup: buildVaultRequestSaveKeyboard(pendingVault.stageId) },
|
|
5004
|
+
)
|
|
5005
|
+
.catch(() => {})
|
|
5006
|
+
}
|
|
4838
5007
|
}
|
|
4839
5008
|
return
|
|
4840
5009
|
}
|
|
@@ -4871,7 +5040,15 @@ async function handleInbound(
|
|
|
4871
5040
|
while (existing.has(slug)) slug = `${slugBase}_${n++}`
|
|
4872
5041
|
const write = defaultVaultWrite(slug, staged.detection.matched_text, cached.passphrase)
|
|
4873
5042
|
if (!write.ok) {
|
|
4874
|
-
|
|
5043
|
+
// Route P0a markers (#969) through the structured renderer so
|
|
5044
|
+
// a "new key, needs operator approval" failure surfaces a
|
|
5045
|
+
// host-CLI hint instead of a raw stderr blob.
|
|
5046
|
+
const parsed = parseVaultCliError(write.output)
|
|
5047
|
+
const rendered = renderVaultCliError(parsed, { verb: 'save', key: slug })
|
|
5048
|
+
const body = rendered.suppressRaw
|
|
5049
|
+
? rendered.html
|
|
5050
|
+
: `<b>vault write failed:</b>\n${preBlock(write.output)}`
|
|
5051
|
+
await switchroomReply(ctx, body, { html: true })
|
|
4875
5052
|
return
|
|
4876
5053
|
}
|
|
4877
5054
|
secretStaging.delete(staged.chat_id, staged.message_id)
|
|
@@ -5939,16 +6116,37 @@ function runVaultCli(args: string[], passphrase: string, stdinValue?: string): {
|
|
|
5939
6116
|
}
|
|
5940
6117
|
}
|
|
5941
6118
|
|
|
6119
|
+
/**
|
|
6120
|
+
* Render a vault-CLI failure as Telegram HTML. Routes recognised P0a
|
|
6121
|
+
* stderr markers (VAULT-SANDBOX-CONTEXT / VAULT-NEEDS-APPROVAL /
|
|
6122
|
+
* VAULT-BROKER-UNREACHABLE / VAULT-BROKER-DENIED) through the structured
|
|
6123
|
+
* renderer; falls back to a raw pre-block for anything else.
|
|
6124
|
+
*/
|
|
6125
|
+
function renderVaultOpFailure(
|
|
6126
|
+
verbLabel: 'list' | 'get' | 'set' | 'delete',
|
|
6127
|
+
cliOutput: string,
|
|
6128
|
+
key: string | undefined,
|
|
6129
|
+
): string {
|
|
6130
|
+
const parsed = parseVaultCliError(cliOutput)
|
|
6131
|
+
// Map the gateway-internal op label onto the renderer's verb. 'delete'
|
|
6132
|
+
// surfaces in the host hint as `switchroom vault remove <key>` (the
|
|
6133
|
+
// canonical CLI name); 'list' has no key.
|
|
6134
|
+
const verb = verbLabel === 'delete' ? 'remove' : verbLabel
|
|
6135
|
+
const rendered = renderVaultCliError(parsed, { verb, key })
|
|
6136
|
+
if (rendered.suppressRaw) return rendered.html
|
|
6137
|
+
return `<b>vault ${verbLabel} failed:</b>\n${preBlock(cliOutput)}`
|
|
6138
|
+
}
|
|
6139
|
+
|
|
5942
6140
|
async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' | 'set' | 'delete', key: string | undefined, passphrase: string, setValue: string | undefined): Promise<void> {
|
|
5943
6141
|
if (op === 'list') {
|
|
5944
6142
|
const r = runVaultCli(['list'], passphrase)
|
|
5945
|
-
if (!r.ok) { await switchroomReply(ctx,
|
|
6143
|
+
if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('list', r.output, undefined), { html: true }); return }
|
|
5946
6144
|
const keys = r.output.split('\n').filter(Boolean)
|
|
5947
6145
|
if (keys.length === 0) { await switchroomReply(ctx, 'Vault is empty.', { html: true }) }
|
|
5948
6146
|
else { await switchroomReply(ctx, `<b>Vault keys (${keys.length}):</b>\n${keys.map(k => `• <code>${escapeHtmlForTg(k)}</code>`).join('\n')}`, { html: true }) }
|
|
5949
6147
|
} else if (op === 'get') {
|
|
5950
6148
|
const r = runVaultCli(['get', key!], passphrase)
|
|
5951
|
-
if (!r.ok) { await switchroomReply(ctx,
|
|
6149
|
+
if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('get', r.output, key), { html: true }); return }
|
|
5952
6150
|
await switchroomReply(ctx, `<code>${escapeHtmlForTg(key!)}</code> =\n<code>${escapeHtmlForTg(r.output)}</code>`, { html: true })
|
|
5953
6151
|
} else if (op === 'set') {
|
|
5954
6152
|
if (setValue === undefined) {
|
|
@@ -5957,11 +6155,11 @@ async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' |
|
|
|
5957
6155
|
return
|
|
5958
6156
|
}
|
|
5959
6157
|
const r = runVaultCli(['set', key!], passphrase, setValue)
|
|
5960
|
-
if (!r.ok) { await switchroomReply(ctx,
|
|
6158
|
+
if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('set', r.output, key), { html: true }) }
|
|
5961
6159
|
else { await switchroomReply(ctx, `✅ <code>${escapeHtmlForTg(key!)}</code> saved to vault.`, { html: true }) }
|
|
5962
6160
|
} else if (op === 'delete') {
|
|
5963
6161
|
const r = runVaultCli(['remove', key!], passphrase)
|
|
5964
|
-
if (!r.ok) { await switchroomReply(ctx,
|
|
6162
|
+
if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('delete', r.output, key), { html: true }) }
|
|
5965
6163
|
else { await switchroomReply(ctx, `✅ <code>${escapeHtmlForTg(key!)}</code> removed from vault.`, { html: true }) }
|
|
5966
6164
|
}
|
|
5967
6165
|
}
|
|
@@ -7479,6 +7677,152 @@ function resolveAgentDirForName(agent: string): string | null {
|
|
|
7479
7677
|
* Authorization mirrors the operator-event callback: only senders on the
|
|
7480
7678
|
* configured allowlist get to act on the buttons.
|
|
7481
7679
|
*/
|
|
7680
|
+
/**
|
|
7681
|
+
* Issue #969 P1a — handle the agent-initiated vault-save approval card
|
|
7682
|
+
* (`vault_request_save` MCP tool).
|
|
7683
|
+
*
|
|
7684
|
+
* Callbacks:
|
|
7685
|
+
* vrs:save:<stageId> — confirm save; write to vault using broker put
|
|
7686
|
+
* with operator-passphrase attestation (#969 P1a)
|
|
7687
|
+
* so even new keys go through in one tap.
|
|
7688
|
+
* vrs:discard:<stageId> — drop the staged secret; never touches disk.
|
|
7689
|
+
* vrs:rename:<stageId> — set up a pending-op intercept so the user's
|
|
7690
|
+
* next message is taken as a new key name.
|
|
7691
|
+
*/
|
|
7692
|
+
async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promise<void> {
|
|
7693
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
7694
|
+
const access = loadAccess()
|
|
7695
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
7696
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
7697
|
+
return
|
|
7698
|
+
}
|
|
7699
|
+
|
|
7700
|
+
const parts = data.split(':')
|
|
7701
|
+
if (parts.length < 3) {
|
|
7702
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
7703
|
+
return
|
|
7704
|
+
}
|
|
7705
|
+
const action = parts[1]
|
|
7706
|
+
const stageId = parts.slice(2).join(':')
|
|
7707
|
+
const pending = pendingVaultRequestSaves.get(stageId)
|
|
7708
|
+
if (!pending) {
|
|
7709
|
+
await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-send.' }).catch(() => {})
|
|
7710
|
+
if (ctx.callbackQuery?.message) {
|
|
7711
|
+
await ctx.api
|
|
7712
|
+
.editMessageText(
|
|
7713
|
+
ctx.callbackQuery.message.chat.id,
|
|
7714
|
+
ctx.callbackQuery.message.message_id,
|
|
7715
|
+
'⌛ <i>This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save.</i>',
|
|
7716
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
7717
|
+
)
|
|
7718
|
+
.catch(() => {})
|
|
7719
|
+
}
|
|
7720
|
+
return
|
|
7721
|
+
}
|
|
7722
|
+
|
|
7723
|
+
if (action === 'discard') {
|
|
7724
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
7725
|
+
await ctx.answerCallbackQuery({ text: '🚫 Discarded' }).catch(() => {})
|
|
7726
|
+
if (pending.card_message_id != null) {
|
|
7727
|
+
await ctx.api
|
|
7728
|
+
.editMessageText(
|
|
7729
|
+
pending.chat_id,
|
|
7730
|
+
pending.card_message_id,
|
|
7731
|
+
`🚫 <i>Discarded. The secret was not written to the vault.</i>`,
|
|
7732
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
7733
|
+
)
|
|
7734
|
+
.catch(() => {})
|
|
7735
|
+
}
|
|
7736
|
+
return
|
|
7737
|
+
}
|
|
7738
|
+
|
|
7739
|
+
if (action === 'rename') {
|
|
7740
|
+
// Set up a pending-op intercept so the user's next message is read
|
|
7741
|
+
// as the new key name. Same shape as the existing /vault set value
|
|
7742
|
+
// capture (gateway.ts uses pendingVaultOps for this).
|
|
7743
|
+
pendingVaultOps.set(pending.chat_id, {
|
|
7744
|
+
kind: 'rename-vault-save',
|
|
7745
|
+
stageId,
|
|
7746
|
+
startedAt: Date.now(),
|
|
7747
|
+
} as PendingVaultOp)
|
|
7748
|
+
await ctx.answerCallbackQuery({ text: 'Send the new key name as your next message.' }).catch(() => {})
|
|
7749
|
+
return
|
|
7750
|
+
}
|
|
7751
|
+
|
|
7752
|
+
if (action === 'save') {
|
|
7753
|
+
// Acknowledge the tap immediately so Telegram doesn't show a
|
|
7754
|
+
// stale "spinning" state on the button while we run the write.
|
|
7755
|
+
await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
|
|
7756
|
+
|
|
7757
|
+
// Fetch the cached passphrase for this chat. If the gateway hasn't
|
|
7758
|
+
// seen the user unlock the vault yet, we can't attest the write —
|
|
7759
|
+
// surface the unlock card via the same path the deferred-secret
|
|
7760
|
+
// flow uses (issue #44).
|
|
7761
|
+
const cached = vaultPassphraseCache.get(pending.chat_id)
|
|
7762
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
7763
|
+
if (pending.card_message_id != null) {
|
|
7764
|
+
await ctx.api
|
|
7765
|
+
.editMessageText(
|
|
7766
|
+
pending.chat_id,
|
|
7767
|
+
pending.card_message_id,
|
|
7768
|
+
`🔒 <b>Vault is locked.</b> Run <code>/vault unlock</code> (or any /vault command) to cache the passphrase, then tap Save again on the next card.`,
|
|
7769
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
7770
|
+
)
|
|
7771
|
+
.catch(() => {})
|
|
7772
|
+
}
|
|
7773
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
7774
|
+
return
|
|
7775
|
+
}
|
|
7776
|
+
|
|
7777
|
+
// Run the write. defaultVaultWrite spawns `switchroom vault set
|
|
7778
|
+
// <key>` with the passphrase env set; the CLI in turn forwards the
|
|
7779
|
+
// passphrase to the broker put as operator-attestation (#969 P1a),
|
|
7780
|
+
// which authorizes new-key creation.
|
|
7781
|
+
const write = defaultVaultWrite(pending.key, pending.value, cached.passphrase)
|
|
7782
|
+
|
|
7783
|
+
if (!write.ok) {
|
|
7784
|
+
// Route through the structured-error renderer from #969 P0b so
|
|
7785
|
+
// failures show the actionable host hint instead of a raw blob.
|
|
7786
|
+
const parsed = parseVaultCliError(write.output)
|
|
7787
|
+
const rendered = renderVaultCliError(parsed, { verb: 'save', key: pending.key })
|
|
7788
|
+
const body = rendered.suppressRaw
|
|
7789
|
+
? rendered.html
|
|
7790
|
+
: `⚠️ vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
|
|
7791
|
+
if (pending.card_message_id != null) {
|
|
7792
|
+
await ctx.api
|
|
7793
|
+
.editMessageText(
|
|
7794
|
+
pending.chat_id,
|
|
7795
|
+
pending.card_message_id,
|
|
7796
|
+
`${body}\n\n<i>Tap a fresh card after fixing the underlying issue.</i>`,
|
|
7797
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
7798
|
+
)
|
|
7799
|
+
.catch(() => {})
|
|
7800
|
+
}
|
|
7801
|
+
// Leave the staged secret in memory until TTL — operator might
|
|
7802
|
+
// retry by re-invoking the same MCP tool, but the value will be
|
|
7803
|
+
// re-staged with a new ID. Drop the current stage.
|
|
7804
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
7805
|
+
return
|
|
7806
|
+
}
|
|
7807
|
+
|
|
7808
|
+
// Success — mask the value in the card for visual confirmation.
|
|
7809
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
7810
|
+
if (pending.card_message_id != null) {
|
|
7811
|
+
await ctx.api
|
|
7812
|
+
.editMessageText(
|
|
7813
|
+
pending.chat_id,
|
|
7814
|
+
pending.card_message_id,
|
|
7815
|
+
`✅ saved as <code>vault:${escapeHtmlForTg(pending.key)}</code> (masked: <code>${escapeHtmlForTg(maskToken(pending.value))}</code>)\n<i>The agent can now reference this as <code>vault:${escapeHtmlForTg(pending.key)}</code>.</i>`,
|
|
7816
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
7817
|
+
)
|
|
7818
|
+
.catch(() => {})
|
|
7819
|
+
}
|
|
7820
|
+
return
|
|
7821
|
+
}
|
|
7822
|
+
|
|
7823
|
+
await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
|
|
7824
|
+
}
|
|
7825
|
+
|
|
7482
7826
|
async function handleVaultDeferCallback(ctx: Context, data: string): Promise<void> {
|
|
7483
7827
|
const senderId = String(ctx.from?.id ?? '')
|
|
7484
7828
|
const access = loadAccess()
|
|
@@ -8087,13 +8431,26 @@ async function executeDeferredSecretSave(
|
|
|
8087
8431
|
|
|
8088
8432
|
const write = defaultVaultWrite(slug, deferred.text, passphrase)
|
|
8089
8433
|
if (!write.ok) {
|
|
8090
|
-
//
|
|
8434
|
+
// Classify the failure via the structured stderr markers emitted by
|
|
8435
|
+
// `switchroom vault` (issue #969 P0a). If it's a recognised marker,
|
|
8436
|
+
// render a clean actionable message instead of dumping the raw
|
|
8437
|
+
// "Vault file not found …" / "VAULT-NEEDS-APPROVAL …" blob the CLI
|
|
8438
|
+
// emits — that was the misleading-error half of #968.
|
|
8439
|
+
//
|
|
8440
|
+
// Keep the deferred entry so the user can retry by tapping again
|
|
8441
|
+
// once the underlying condition is fixed (broker started, host
|
|
8442
|
+
// approval granted, etc.).
|
|
8443
|
+
const parsed = parseVaultCliError(write.output)
|
|
8444
|
+
const rendered = renderVaultCliError(parsed, { verb: "save", key: slug })
|
|
8445
|
+
const body = rendered.suppressRaw
|
|
8446
|
+
? rendered.html
|
|
8447
|
+
: `⚠️ vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
|
|
8091
8448
|
if (cardMessageId != null) {
|
|
8092
8449
|
await ctx.api
|
|
8093
8450
|
.editMessageText(
|
|
8094
8451
|
deferred.chat_id,
|
|
8095
8452
|
cardMessageId,
|
|
8096
|
-
|
|
8453
|
+
`${body}\n\nRe-tap to retry.`,
|
|
8097
8454
|
{
|
|
8098
8455
|
parse_mode: 'HTML',
|
|
8099
8456
|
reply_markup: buildDeferredSecretKeyboard(deferKey).inline_keyboard.length > 0
|
|
@@ -8587,12 +8944,134 @@ bot.command('vault', async ctx => {
|
|
|
8587
8944
|
'/vault lock — lock the broker',
|
|
8588
8945
|
'/vault grant — mint a capability token (inline wizard)',
|
|
8589
8946
|
'/vault grants [agent] — list active capability grants (tap to revoke)',
|
|
8947
|
+
'/vault audit <agent> — unified view of an agent\'s vault access',
|
|
8590
8948
|
'',
|
|
8591
8949
|
'Your passphrase is cached in memory for 30 min after first use.',
|
|
8592
8950
|
].join('\n'), { html: true })
|
|
8593
8951
|
return
|
|
8594
8952
|
}
|
|
8595
8953
|
|
|
8954
|
+
// Issue #969 P2c: /vault audit <agent> — unified view of an agent's
|
|
8955
|
+
// vault access (grants + cron-declared schedule.secrets[]). Single
|
|
8956
|
+
// mental model for operators auditing the agent's credential surface.
|
|
8957
|
+
if (sub === 'audit') {
|
|
8958
|
+
const targetAgent = args[1]
|
|
8959
|
+
if (!targetAgent) {
|
|
8960
|
+
await switchroomReply(ctx, 'Usage: /vault audit <agent>', { html: true })
|
|
8961
|
+
return
|
|
8962
|
+
}
|
|
8963
|
+
// Sanity-check the agent name shape — same charset constraint as
|
|
8964
|
+
// assertSafeAgentName, applied here so we don't blindly shell-out
|
|
8965
|
+
// or read filesystem paths derived from user input.
|
|
8966
|
+
if (!/^[a-z][a-z0-9-]{0,62}$/i.test(targetAgent)) {
|
|
8967
|
+
await switchroomReply(ctx, '⚠️ Invalid agent name shape.', { html: true })
|
|
8968
|
+
return
|
|
8969
|
+
}
|
|
8970
|
+
|
|
8971
|
+
// 1. Fetch grants from broker, filtered to this agent.
|
|
8972
|
+
const grantsResult = await listGrantsViaBroker(targetAgent)
|
|
8973
|
+
let readGrants: Array<{ id: string; keys: string[]; expiresAt: number | null }> = []
|
|
8974
|
+
let writeGrants: Array<{ id: string; keys: string[]; expiresAt: number | null }> = []
|
|
8975
|
+
let grantsError: string | null = null
|
|
8976
|
+
if (grantsResult.kind === 'unreachable') {
|
|
8977
|
+
grantsError = 'broker unreachable'
|
|
8978
|
+
} else if (grantsResult.kind === 'error') {
|
|
8979
|
+
grantsError = grantsResult.msg
|
|
8980
|
+
} else {
|
|
8981
|
+
for (const g of grantsResult.grants) {
|
|
8982
|
+
if (g.key_allow.length > 0) {
|
|
8983
|
+
readGrants.push({ id: g.id, keys: g.key_allow, expiresAt: g.expires_at })
|
|
8984
|
+
}
|
|
8985
|
+
if (g.write_allow && g.write_allow.length > 0) {
|
|
8986
|
+
writeGrants.push({ id: g.id, keys: g.write_allow, expiresAt: g.expires_at })
|
|
8987
|
+
}
|
|
8988
|
+
}
|
|
8989
|
+
}
|
|
8990
|
+
|
|
8991
|
+
// 2. Load schedule[i].secrets[] from local config for this agent.
|
|
8992
|
+
let cronEntries: Array<{ index: number; secrets: string[]; cron?: string }> = []
|
|
8993
|
+
let configError: string | null = null
|
|
8994
|
+
try {
|
|
8995
|
+
const cfg = loadSwitchroomConfig()
|
|
8996
|
+
const agentCfg = cfg.agents?.[targetAgent]
|
|
8997
|
+
if (!agentCfg) {
|
|
8998
|
+
configError = `agent '${targetAgent}' not found in switchroom.yaml`
|
|
8999
|
+
} else {
|
|
9000
|
+
const schedule = (agentCfg as { schedule?: Array<{ secrets?: string[]; cron?: string }> }).schedule
|
|
9001
|
+
if (Array.isArray(schedule)) {
|
|
9002
|
+
schedule.forEach((entry, i) => {
|
|
9003
|
+
if (entry?.secrets && Array.isArray(entry.secrets) && entry.secrets.length > 0) {
|
|
9004
|
+
cronEntries.push({
|
|
9005
|
+
index: i,
|
|
9006
|
+
secrets: entry.secrets,
|
|
9007
|
+
cron: typeof entry.cron === 'string' ? entry.cron : undefined,
|
|
9008
|
+
})
|
|
9009
|
+
}
|
|
9010
|
+
})
|
|
9011
|
+
}
|
|
9012
|
+
}
|
|
9013
|
+
} catch (err) {
|
|
9014
|
+
configError = (err instanceof Error ? err.message : String(err))
|
|
9015
|
+
}
|
|
9016
|
+
|
|
9017
|
+
// 3. Compose the rendered audit view.
|
|
9018
|
+
const lines: string[] = [`<b>🔐 ${escapeHtmlForTg(targetAgent)} — vault access</b>`, '']
|
|
9019
|
+
|
|
9020
|
+
if (grantsError) {
|
|
9021
|
+
lines.push(`<i>Grants: ⚠️ ${escapeHtmlForTg(grantsError)}</i>`)
|
|
9022
|
+
lines.push('')
|
|
9023
|
+
} else {
|
|
9024
|
+
lines.push('<b>Read grants:</b>')
|
|
9025
|
+
if (readGrants.length === 0) {
|
|
9026
|
+
lines.push(' <i>none</i>')
|
|
9027
|
+
} else {
|
|
9028
|
+
for (const g of readGrants) {
|
|
9029
|
+
const expiry = g.expiresAt
|
|
9030
|
+
? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
|
|
9031
|
+
: 'no expiry'
|
|
9032
|
+
const keysJoined = g.keys.join(', ')
|
|
9033
|
+
lines.push(` <code>${escapeHtmlForTg(g.id)}</code> · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
|
|
9034
|
+
}
|
|
9035
|
+
}
|
|
9036
|
+
lines.push('')
|
|
9037
|
+
lines.push('<b>Write grants:</b>')
|
|
9038
|
+
if (writeGrants.length === 0) {
|
|
9039
|
+
lines.push(' <i>none</i>')
|
|
9040
|
+
} else {
|
|
9041
|
+
for (const g of writeGrants) {
|
|
9042
|
+
const expiry = g.expiresAt
|
|
9043
|
+
? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
|
|
9044
|
+
: 'no expiry'
|
|
9045
|
+
const keysJoined = g.keys.join(', ')
|
|
9046
|
+
lines.push(` <code>${escapeHtmlForTg(g.id)}</code> · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
|
|
9047
|
+
}
|
|
9048
|
+
}
|
|
9049
|
+
lines.push('')
|
|
9050
|
+
}
|
|
9051
|
+
|
|
9052
|
+
lines.push('<b>Cron-declared secrets:</b>')
|
|
9053
|
+
if (configError) {
|
|
9054
|
+
lines.push(` <i>⚠️ ${escapeHtmlForTg(configError)}</i>`)
|
|
9055
|
+
} else if (cronEntries.length === 0) {
|
|
9056
|
+
lines.push(' <i>none</i>')
|
|
9057
|
+
} else {
|
|
9058
|
+
for (const entry of cronEntries) {
|
|
9059
|
+
const cronLabel = entry.cron ? ` (<code>${escapeHtmlForTg(entry.cron)}</code>)` : ''
|
|
9060
|
+
const keysJoined = entry.secrets.join(', ')
|
|
9061
|
+
lines.push(` schedule[${entry.index}]${cronLabel}: ${escapeHtmlForTg(keysJoined)}`)
|
|
9062
|
+
}
|
|
9063
|
+
}
|
|
9064
|
+
lines.push('')
|
|
9065
|
+
lines.push(
|
|
9066
|
+
`<i>Summary: ${readGrants.length} read grant${readGrants.length === 1 ? '' : 's'}, ` +
|
|
9067
|
+
`${writeGrants.length} write grant${writeGrants.length === 1 ? '' : 's'}, ` +
|
|
9068
|
+
`${cronEntries.length} cron entr${cronEntries.length === 1 ? 'y' : 'ies'}.</i>`,
|
|
9069
|
+
)
|
|
9070
|
+
|
|
9071
|
+
await switchroomReply(ctx, lines.join('\n'), { html: true })
|
|
9072
|
+
return
|
|
9073
|
+
}
|
|
9074
|
+
|
|
8596
9075
|
// Issue #228: /vault grants [agent] — list active grants grouped by agent
|
|
8597
9076
|
if (sub === 'grants') {
|
|
8598
9077
|
const agentFilter = args[1] // optional agent name filter
|
|
@@ -8955,6 +9434,15 @@ bot.on('callback_query:data', async ctx => {
|
|
|
8955
9434
|
return
|
|
8956
9435
|
}
|
|
8957
9436
|
|
|
9437
|
+
// Issue #969 P1a: agent-initiated vault-save approval card.
|
|
9438
|
+
// vrs:save:<stageId> — write the staged value, edit card to success
|
|
9439
|
+
// vrs:discard:<stageId> — drop the staged value, edit card to discarded
|
|
9440
|
+
// vrs:rename:<stageId> — prompt for a new key name, then resume
|
|
9441
|
+
if (data.startsWith('vrs:')) {
|
|
9442
|
+
await handleVaultRequestSaveCallback(ctx, data)
|
|
9443
|
+
return
|
|
9444
|
+
}
|
|
9445
|
+
|
|
8958
9446
|
// ask_user callback (#574). aq:<idx>:<askId>. Same authorization
|
|
8959
9447
|
// gate as permission buttons — only allowFrom users can answer.
|
|
8960
9448
|
// Tapped option resolves the originating ask_user tool call's
|