switchroom 0.15.12 → 0.15.14
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/dist/agent-scheduler/index.js +324 -14
- package/dist/auth-broker/index.js +61 -4
- package/dist/cli/notion-write-pretool.mjs +61 -4
- package/dist/cli/switchroom.js +402 -113
- package/dist/host-control/main.js +61 -4
- package/dist/vault/approvals/kernel-server.js +62 -5
- package/dist/vault/broker/server.js +62 -5
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +30 -13
- package/profiles/_shared/agent-self-service.md.hbs +37 -0
- package/telegram-plugin/bridge/bridge.ts +38 -1
- package/telegram-plugin/dist/bridge/bridge.js +31 -1
- package/telegram-plugin/dist/gateway/gateway.js +536 -53
- package/telegram-plugin/dist/server.js +31 -1
- package/telegram-plugin/gateway/gateway.ts +169 -6
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -1
- package/telegram-plugin/gateway/ipc-server.ts +29 -0
- package/telegram-plugin/gateway/linear-activity.ts +145 -0
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/scoped-approval.ts +253 -0
- package/telegram-plugin/tests/bridge-liveness-override.test.ts +21 -0
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +54 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +1 -1
- package/telegram-plugin/tests/linear-create-issue.test.ts +211 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +13 -0
- package/telegram-plugin/tests/runtime-metrics.test.ts +9 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +254 -0
- package/telegram-plugin/tests/send-outbound-wiring.test.ts +63 -0
|
@@ -24221,7 +24221,7 @@ async function main() {
|
|
|
24221
24221
|
onStatus,
|
|
24222
24222
|
log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}
|
|
24223
24223
|
`),
|
|
24224
|
-
livenessFilePath: join5(STATE_DIR, ".bridge-alive")
|
|
24224
|
+
livenessFilePath: process.env.SWITCHROOM_BRIDGE_ALIVE_PATH ?? join5(STATE_DIR, ".bridge-alive")
|
|
24225
24225
|
});
|
|
24226
24226
|
if (ipc.isConnected()) {
|
|
24227
24227
|
process.stderr.write(`telegram bridge: connected to gateway at ${SOCKET_PATH}
|
|
@@ -24654,6 +24654,36 @@ var init_bridge = __esm(async () => {
|
|
|
24654
24654
|
},
|
|
24655
24655
|
required: ["agent_session_id", "type"]
|
|
24656
24656
|
}
|
|
24657
|
+
},
|
|
24658
|
+
{
|
|
24659
|
+
name: "linear_create_issue",
|
|
24660
|
+
description: 'File a new Linear issue from a Telegram message the operator flagged for capture (#2312). Use this when a turn was triggered by a capture reaction (the inbound carries event="reaction" with a capture emoji like \uD83D\uDC68\u200d\uD83D\uDCBB or \uD83D\uDCCC) \u2014 turn the reacted message + any relevant thread context into a well-formed issue. Write a crisp imperative title and a body that captures the ask, the context, and any acceptance criteria you can infer; the agent files it AS its own Linear app actor. Team is auto-resolved when the workspace has a single team; if there are multiple it returns text asking for an explicit team_id. Pass dedup_key (e.g. the chat_id:message_id of the reacted message) so a re-react of the same message does not file a duplicate. Resolves the agent\'s Linear app token from the vault; on VAULT-BROKER-DENIED it returns text instructing you to vault_request_access for `linear/<agent>/token`. Returns "Filed: <title> \u2192 <url>" on success \u2014 reply that link to the operator in plain text.',
|
|
24661
|
+
inputSchema: {
|
|
24662
|
+
type: "object",
|
|
24663
|
+
properties: {
|
|
24664
|
+
title: {
|
|
24665
|
+
type: "string",
|
|
24666
|
+
description: 'Issue title \u2014 a crisp, imperative one-liner (e.g. "Fix duplicate webhook retries on Brevo sync").'
|
|
24667
|
+
},
|
|
24668
|
+
body: {
|
|
24669
|
+
type: "string",
|
|
24670
|
+
description: "Issue description (Markdown). Capture the ask, relevant context from the message/thread, and any acceptance criteria you can infer."
|
|
24671
|
+
},
|
|
24672
|
+
team_id: {
|
|
24673
|
+
type: "string",
|
|
24674
|
+
description: "Optional Linear team id. Omit to auto-resolve when the workspace has a single team; required only when the workspace has multiple teams."
|
|
24675
|
+
},
|
|
24676
|
+
dedup_key: {
|
|
24677
|
+
type: "string",
|
|
24678
|
+
description: 'Optional idempotency key (use the reacted message identity, e.g. "<chat_id>:<message_id>"). A prior capture with the same key short-circuits to "Already filed: <url>".'
|
|
24679
|
+
},
|
|
24680
|
+
priority: {
|
|
24681
|
+
type: "number",
|
|
24682
|
+
description: "Optional Linear priority (0 none, 1 urgent, 2 high, 3 normal, 4 low)."
|
|
24683
|
+
}
|
|
24684
|
+
},
|
|
24685
|
+
required: ["title", "body"]
|
|
24686
|
+
}
|
|
24657
24687
|
}
|
|
24658
24688
|
];
|
|
24659
24689
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
|
|
@@ -363,6 +363,7 @@ import type {
|
|
|
363
363
|
PtyPartialForward,
|
|
364
364
|
InboundMessage,
|
|
365
365
|
InjectInboundMessage,
|
|
366
|
+
SendOutboundMessage,
|
|
366
367
|
QuotaWallDetectedMessage,
|
|
367
368
|
PermissionEvent,
|
|
368
369
|
} from './ipc-protocol.js'
|
|
@@ -420,6 +421,14 @@ import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.
|
|
|
420
421
|
import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
|
|
421
422
|
import { formatPermissionCardBody, describeGrant, naturalAction, formatPermissionResumeMessage } from '../permission-title.js'
|
|
422
423
|
import { resolveScopedAllowChoices, isRulePersisted } from '../permission-rule.js'
|
|
424
|
+
import {
|
|
425
|
+
type ScopedGrantStore,
|
|
426
|
+
scopedApprovalTtlMs,
|
|
427
|
+
resolveTimeBox,
|
|
428
|
+
recordScopedGrant,
|
|
429
|
+
lookupScopedGrant,
|
|
430
|
+
sweepScopedGrants,
|
|
431
|
+
} from '../scoped-approval.js'
|
|
423
432
|
import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
|
|
424
433
|
import {
|
|
425
434
|
readClaudeJsonOverage,
|
|
@@ -467,7 +476,7 @@ import {
|
|
|
467
476
|
listGrantsViaBroker,
|
|
468
477
|
revokeGrantViaBroker,
|
|
469
478
|
} from '../../src/vault/broker/client.js'
|
|
470
|
-
import { emitLinearAgentActivity } from './linear-activity.js'
|
|
479
|
+
import { emitLinearAgentActivity, createLinearIssue } from './linear-activity.js'
|
|
471
480
|
import {
|
|
472
481
|
approvalRequest,
|
|
473
482
|
approvalConsume,
|
|
@@ -3651,6 +3660,14 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
|
|
|
3651
3660
|
}
|
|
3652
3661
|
}
|
|
3653
3662
|
|
|
3663
|
+
// "⏱ 30 min" scoped-approval store (the middle tier between Allow-once and
|
|
3664
|
+
// 🔁 Always). Operator-tapped, gateway-side ONLY (never pushed to the
|
|
3665
|
+
// bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
|
|
3666
|
+
// agent name for per-agent isolation. All policy lives in
|
|
3667
|
+
// ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
|
|
3668
|
+
const scopedGrants: ScopedGrantStore = new Map()
|
|
3669
|
+
const selfAgentName = (): string => process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
3670
|
+
|
|
3654
3671
|
// `ask_user` MCP tool — open prompts awaiting a user button-tap.
|
|
3655
3672
|
// Keyed by askId (8 hex chars from generateAskId). Each entry holds
|
|
3656
3673
|
// the deferred promise that resolves the originating tool call, the
|
|
@@ -4246,6 +4263,9 @@ const pendingStateReaper = setInterval(() => {
|
|
|
4246
4263
|
for (const [k, v] of vaultPassphraseCache) {
|
|
4247
4264
|
if (now > v.expiresAt) vaultPassphraseCache.delete(k)
|
|
4248
4265
|
}
|
|
4266
|
+
// Drop expired "⏱ 30 min" scoped grants. (Lookup already fails closed on
|
|
4267
|
+
// expiry; this just keeps the map from accumulating dead entries.)
|
|
4268
|
+
sweepScopedGrants(scopedGrants, now)
|
|
4249
4269
|
for (const [k, v] of deferredSecrets) {
|
|
4250
4270
|
if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
|
|
4251
4271
|
}
|
|
@@ -5591,10 +5611,18 @@ const pendingPermissionBuffer = createPendingPermissionBuffer()
|
|
|
5591
5611
|
* rebuilds this row. callback_data stays tiny (verb + 5-char id) so we
|
|
5592
5612
|
* never approach Telegram's 64-byte ceiling.
|
|
5593
5613
|
*/
|
|
5594
|
-
function buildPermissionActionRow(
|
|
5614
|
+
function buildPermissionActionRow(
|
|
5615
|
+
requestId: string,
|
|
5616
|
+
showAlways: boolean,
|
|
5617
|
+
showTimeBox = false,
|
|
5618
|
+
): InlineKeyboard {
|
|
5595
5619
|
const kb = new InlineKeyboard()
|
|
5596
5620
|
.text('❌ Deny', `perm:deny:${requestId}`)
|
|
5597
5621
|
.text('✅ Allow once', `perm:allow:${requestId}`)
|
|
5622
|
+
// "⏱ 30 min" sits between once and always. Only shown for a narrow,
|
|
5623
|
+
// non-destructive scope (resolveTimeBox decides); broad/MCP/destructive
|
|
5624
|
+
// requests get once/always only.
|
|
5625
|
+
if (showTimeBox) kb.text('⏱ 30 min', `perm:tmb:${requestId}`)
|
|
5598
5626
|
if (showAlways) kb.text('🔁 Always…', `perm:always:${requestId}`)
|
|
5599
5627
|
return kb
|
|
5600
5628
|
}
|
|
@@ -5978,6 +6006,32 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5978
6006
|
|
|
5979
6007
|
onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
|
|
5980
6008
|
const { requestId, toolName, description, inputPreview } = msg
|
|
6009
|
+
// "⏱ 30 min" short-circuit: if the operator tapped a live scoped grant
|
|
6010
|
+
// covering this exact request, auto-allow without posting a card. CRITICAL:
|
|
6011
|
+
// dispatch WITHOUT a `rule` so the bridge does NOT cache it untimed
|
|
6012
|
+
// (sessionAllowRules has no TTL — a `rule` here would silently promote the
|
|
6013
|
+
// 30-min window to session-forever). The fixed window lives only in
|
|
6014
|
+
// scopedGrants. lookupScopedGrant fails closed on expiry and on a
|
|
6015
|
+
// destructive Bash command matching a family grant. No card, no pending
|
|
6016
|
+
// entry, no awaiting reaction — the turn just continues, silently.
|
|
6017
|
+
const scopedTtl = scopedApprovalTtlMs()
|
|
6018
|
+
if (scopedTtl > 0) {
|
|
6019
|
+
const hit = lookupScopedGrant(scopedGrants, selfAgentName(), toolName, inputPreview, Date.now())
|
|
6020
|
+
if (hit) {
|
|
6021
|
+
// Silent auto-allow — no card was posted and the turn was never parked
|
|
6022
|
+
// on the awaiting glyph, so we intentionally OMIT the resume-glyph flip
|
|
6023
|
+
// and the agent-voiced resume message (posting "got it, continuing" on
|
|
6024
|
+
// every auto-allowed call is the exact noise this tier removes). The
|
|
6025
|
+
// `no-card-verdict` sentinel below exempts this callsite from the
|
|
6026
|
+
// resume-guard pairing test.
|
|
6027
|
+
dispatchPermissionVerdict({ type: 'permission', requestId, behavior: 'allow' }) // no-card-verdict
|
|
6028
|
+
process.stderr.write(
|
|
6029
|
+
`telegram gateway: scoped-approval auto-allow tool=${toolName} rule="${hit}" ` +
|
|
6030
|
+
`request=${requestId} (time-boxed window)\n`,
|
|
6031
|
+
)
|
|
6032
|
+
return
|
|
6033
|
+
}
|
|
6034
|
+
}
|
|
5981
6035
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
|
|
5982
6036
|
// Natural-language card body — a plain sentence ("Gymbro wants to
|
|
5983
6037
|
// edit: supplement-log.md" + a why-line), never a raw tool id.
|
|
@@ -5996,8 +6050,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5996
6050
|
// any file ⚠️). The "🔁 Always…" button only appears when we can
|
|
5997
6051
|
// synthesize a meaningful rule for this tool; unknown tools get the
|
|
5998
6052
|
// two-button row only.
|
|
5999
|
-
const
|
|
6000
|
-
const
|
|
6053
|
+
const scopeChoices = resolveScopedAllowChoices(toolName, inputPreview)
|
|
6054
|
+
const showAlways = scopeChoices != null
|
|
6055
|
+
// Offer "⏱ 30 min" only for a narrow, non-destructive scope, and only
|
|
6056
|
+
// when the tier is enabled (TTL > 0).
|
|
6057
|
+
const showTimeBox = scopedApprovalTtlMs() > 0 &&
|
|
6058
|
+
resolveTimeBox(toolName, inputPreview, scopeChoices) != null
|
|
6059
|
+
const keyboard = buildPermissionActionRow(requestId, showAlways, showTimeBox)
|
|
6001
6060
|
// Route the card to the SAME place the post-verdict resume message
|
|
6002
6061
|
// lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
|
|
6003
6062
|
// there's an active turn — so a supergroup agent's card appears IN the
|
|
@@ -6488,6 +6547,10 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6488
6547
|
process.stderr.write(
|
|
6489
6548
|
`telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}\n`,
|
|
6490
6549
|
)
|
|
6550
|
+
// #2307 Tier-1 observability: a climbing counter means the cron session is
|
|
6551
|
+
// down (registered then died, or never came up) — the Tier-1 saving is
|
|
6552
|
+
// lost for this fire. Surfaced via the unified runtime-metrics fan-out.
|
|
6553
|
+
emitRuntimeMetric({ kind: 'cron_fell_back_to_main', agent: msg.agentName, prompt_key: promptKey })
|
|
6491
6554
|
}
|
|
6492
6555
|
// Status-silent (§2.4): a cron fire delivered to the CRON session must NOT
|
|
6493
6556
|
// set the MAIN agent's currentTurn. But a fire that LANDED on the main
|
|
@@ -6506,6 +6569,47 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6506
6569
|
}
|
|
6507
6570
|
},
|
|
6508
6571
|
|
|
6572
|
+
// #2307 Tier-0 action tier — a MODEL-FREE outbound post. The agent-scheduler
|
|
6573
|
+
// fires this for a `kind: action` `telegram-message`; the gateway posts the
|
|
6574
|
+
// (already-substituted) text to the agent's OWN chat with NO model: no
|
|
6575
|
+
// inject_inbound, no session wake, no currentTurn mutation. Two fences:
|
|
6576
|
+
// 1. agentName must match this gateway's own SWITCHROOM_AGENT_NAME.
|
|
6577
|
+
// 2. chatId must be an allowlisted chat for this agent (assertAllowedChat).
|
|
6578
|
+
// An action carries no chat target of its own — the scheduler supplies the
|
|
6579
|
+
// agent's own chat — so 2 is belt-and-braces against a malformed/foreign id.
|
|
6580
|
+
onSendOutbound(_client: IpcClient, msg: SendOutboundMessage) {
|
|
6581
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
6582
|
+
if (self && msg.agentName !== self) {
|
|
6583
|
+
process.stderr.write(
|
|
6584
|
+
`telegram gateway: send_outbound rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
6585
|
+
)
|
|
6586
|
+
return
|
|
6587
|
+
}
|
|
6588
|
+
try {
|
|
6589
|
+
assertAllowedChat(msg.chatId)
|
|
6590
|
+
} catch (err) {
|
|
6591
|
+
process.stderr.write(
|
|
6592
|
+
`telegram gateway: send_outbound rejected — ${(err as Error).message}\n`,
|
|
6593
|
+
)
|
|
6594
|
+
return
|
|
6595
|
+
}
|
|
6596
|
+
const threadId = msg.threadId
|
|
6597
|
+
const parseMode = msg.parseMode === 'text' ? undefined : 'HTML'
|
|
6598
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send.
|
|
6599
|
+
// General topic (thread 1) sends omit message_thread_id per the outbound convention.
|
|
6600
|
+
void swallowingApiCall(
|
|
6601
|
+
() =>
|
|
6602
|
+
bot.api.sendMessage(msg.chatId, msg.text, {
|
|
6603
|
+
...(parseMode ? { parse_mode: parseMode } : {}),
|
|
6604
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
6605
|
+
}),
|
|
6606
|
+
{ chat_id: msg.chatId, verb: 'cron-action-send', ...(threadId != null ? { threadId } : {}) },
|
|
6607
|
+
)
|
|
6608
|
+
process.stderr.write(
|
|
6609
|
+
`telegram gateway: send_outbound agent=${msg.agentName} chat=${msg.chatId} thread=${threadId ?? '-'} len=${msg.text.length}\n`,
|
|
6610
|
+
)
|
|
6611
|
+
},
|
|
6612
|
+
|
|
6509
6613
|
// The wedge-watchdog detected claude's /rate-limit-options weekly-quota menu
|
|
6510
6614
|
// (a TUI wall that never produced a 429, so the inference-path auto-fallback
|
|
6511
6615
|
// never fired). Trigger the SAME fleet auto-fallback the 429 path uses,
|
|
@@ -6720,6 +6824,7 @@ const ALLOWED_TOOLS = new Set([
|
|
|
6720
6824
|
'vault_request_access',
|
|
6721
6825
|
'request_secret',
|
|
6722
6826
|
'linear_agent_activity',
|
|
6827
|
+
'linear_create_issue',
|
|
6723
6828
|
])
|
|
6724
6829
|
|
|
6725
6830
|
async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
@@ -6767,6 +6872,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
|
|
|
6767
6872
|
return executeRequestSecret(args)
|
|
6768
6873
|
case 'linear_agent_activity':
|
|
6769
6874
|
return executeLinearAgentActivity(args)
|
|
6875
|
+
case 'linear_create_issue':
|
|
6876
|
+
return executeLinearCreateIssue(args)
|
|
6770
6877
|
default:
|
|
6771
6878
|
throw new Error(`unknown tool: ${tool}`)
|
|
6772
6879
|
}
|
|
@@ -6802,6 +6909,10 @@ async function executeLinearAgentActivity(args: Record<string, unknown>): Promis
|
|
|
6802
6909
|
return emitLinearAgentActivity(args)
|
|
6803
6910
|
}
|
|
6804
6911
|
|
|
6912
|
+
async function executeLinearCreateIssue(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
6913
|
+
return createLinearIssue(args)
|
|
6914
|
+
}
|
|
6915
|
+
|
|
6805
6916
|
async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
6806
6917
|
const chat_id = args.chat_id as string
|
|
6807
6918
|
if (!chat_id) throw new Error('update_checklist: chat_id is required')
|
|
@@ -18962,7 +19073,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
18962
19073
|
}
|
|
18963
19074
|
|
|
18964
19075
|
// Permission request buttons.
|
|
18965
|
-
const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data)
|
|
19076
|
+
const m = /^perm:(allow|deny|always|asn|asb|back|tmb):([a-km-z]{5})$/.exec(data)
|
|
18966
19077
|
if (!m) { await ctx.answerCallbackQuery().catch(() => {}); return }
|
|
18967
19078
|
const access = loadAccess()
|
|
18968
19079
|
const senderId = String(ctx.from.id)
|
|
@@ -18977,7 +19088,10 @@ bot.on('callback_query:data', async ctx => {
|
|
|
18977
19088
|
if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
|
|
18978
19089
|
let keyboard: InlineKeyboard
|
|
18979
19090
|
if (behavior === 'back') {
|
|
18980
|
-
|
|
19091
|
+
const backChoices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
19092
|
+
const backTimeBox = scopedApprovalTtlMs() > 0 &&
|
|
19093
|
+
resolveTimeBox(details.tool_name, details.input_preview, backChoices) != null
|
|
19094
|
+
keyboard = buildPermissionActionRow(request_id, true, backTimeBox)
|
|
18981
19095
|
} else {
|
|
18982
19096
|
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
18983
19097
|
if (choices == null) {
|
|
@@ -19201,6 +19315,55 @@ bot.on('callback_query:data', async ctx => {
|
|
|
19201
19315
|
return
|
|
19202
19316
|
}
|
|
19203
19317
|
|
|
19318
|
+
// "⏱ 30 min" — record a fixed-window scoped grant for the NARROW scope,
|
|
19319
|
+
// then allow the in-flight call. Mirrors the asn/asb structure: dispatch
|
|
19320
|
+
// the verdict immediately, then edit the card. CRITICAL: the verdict
|
|
19321
|
+
// carries NO `rule` (unlike asn/asb), so the bridge does not cache it
|
|
19322
|
+
// untimed — the window lives only in scopedGrants, gateway-side.
|
|
19323
|
+
if (behavior === 'tmb') {
|
|
19324
|
+
const details = pendingPermissions.get(request_id)
|
|
19325
|
+
if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
|
|
19326
|
+
const ttl = scopedApprovalTtlMs()
|
|
19327
|
+
if (ttl <= 0) { await ctx.answerCallbackQuery({ text: 'Time-boxed approvals are disabled.' }).catch(() => {}); return }
|
|
19328
|
+
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
19329
|
+
const tb = resolveTimeBox(details.tool_name, details.input_preview, choices)
|
|
19330
|
+
if (!tb) { await ctx.answerCallbackQuery({ text: 'This action can\'t be time-boxed.' }).catch(() => {}); return }
|
|
19331
|
+
const agentName = selfAgentName()
|
|
19332
|
+
if (!agentName) { await ctx.answerCallbackQuery({ text: 'Time-box needs SWITCHROOM_AGENT_NAME — gateway is misconfigured.' }).catch(() => {}); return }
|
|
19333
|
+
|
|
19334
|
+
pendingPermissions.delete(request_id)
|
|
19335
|
+
// (1) Allow the in-flight call NOW — no `rule` (keeps the window
|
|
19336
|
+
// strictly gateway-side; the bridge must not cache it untimed).
|
|
19337
|
+
dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior: 'allow' })
|
|
19338
|
+
// (2) Record the fixed-window grant so matching calls auto-allow.
|
|
19339
|
+
recordScopedGrant(scopedGrants, agentName, tb.rule, Date.now(), ttl)
|
|
19340
|
+
resumeReactionAfterVerdict()
|
|
19341
|
+
postPermissionResumeMessage({
|
|
19342
|
+
behavior: 'allow',
|
|
19343
|
+
action: naturalAction(details.tool_name, details.input_preview),
|
|
19344
|
+
})
|
|
19345
|
+
process.stderr.write(
|
|
19346
|
+
`telegram gateway: scoped-approval granted rule="${tb.rule}" agent=${agentName} ` +
|
|
19347
|
+
`ttl_ms=${ttl} (request_id=${request_id})\n`,
|
|
19348
|
+
)
|
|
19349
|
+
|
|
19350
|
+
const mins = Math.max(1, Math.round(ttl / 60_000))
|
|
19351
|
+
// Honest card: state the real BREADTH (e.g. "any `git` command"), not
|
|
19352
|
+
// just the rule, plus the window — consent covers both (access-model
|
|
19353
|
+
// honest-card contract).
|
|
19354
|
+
const sourceMsg = ctx.callbackQuery?.message
|
|
19355
|
+
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
19356
|
+
? escapeHtmlForTg(sourceMsg.text)
|
|
19357
|
+
: ''
|
|
19358
|
+
const editLabel = `⏱ <b>Allowed for ${mins} min — ${escapeHtmlForTg(tb.breadth)}</b> · re-asks after that, and now for anything else`
|
|
19359
|
+
await finalizeCallback(ctx, {
|
|
19360
|
+
ackText: `⏱ Allowed for ${mins} min`.slice(0, 200),
|
|
19361
|
+
newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
|
|
19362
|
+
parseMode: 'HTML',
|
|
19363
|
+
})
|
|
19364
|
+
return
|
|
19365
|
+
}
|
|
19366
|
+
|
|
19204
19367
|
// Forward permission decision to connected bridges. Capture the work
|
|
19205
19368
|
// phrase BEFORE deleting the pending entry — postPermissionResumeMessage
|
|
19206
19369
|
// (fired in synthInbound below) names the resumed work.
|
|
@@ -413,6 +413,35 @@ export interface QuotaWallDetectedMessage {
|
|
|
413
413
|
resetAt?: number;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* #2307 (Tier-0 action tier) — a MODEL-FREE outbound post. Sent by the
|
|
418
|
+
* in-agent scheduler when a `kind: action` cron's `telegram-message` fires:
|
|
419
|
+
* the gateway posts `text` to the agent's OWN chat with no model involvement
|
|
420
|
+
* (NO `inject_inbound`, NO session wake, NO `currentTurn` mutation). This is
|
|
421
|
+
* the deliberate counterpart to `inject_inbound` — the one ClientToGateway
|
|
422
|
+
* verb that produces an outbound WITHOUT a turn.
|
|
423
|
+
*
|
|
424
|
+
* Trust model: same as `inject_inbound` — the socket is per-agent inside the
|
|
425
|
+
* container, only that-UID processes can connect. `agentName` is validated
|
|
426
|
+
* server-side, and the gateway FENCES `chatId` to the agent's own configured
|
|
427
|
+
* chat (DM allowlist / forum_chat_id) and rejects a foreign chat — an action
|
|
428
|
+
* can never post elsewhere (the action spec carries no chat target; the
|
|
429
|
+
* scheduler supplies the agent's own).
|
|
430
|
+
*/
|
|
431
|
+
export interface SendOutboundMessage {
|
|
432
|
+
type: "send_outbound";
|
|
433
|
+
/** Target agent — gateway verifies it matches its own SWITCHROOM_AGENT_NAME. */
|
|
434
|
+
agentName: string;
|
|
435
|
+
/** Agent's own chat id (fenced server-side against the agent's config). */
|
|
436
|
+
chatId: string;
|
|
437
|
+
/** Forum topic thread id (General/unset omitted; 1 is stripped on send). */
|
|
438
|
+
threadId?: number;
|
|
439
|
+
/** Message body — already substitution-resolved by the action engine. */
|
|
440
|
+
text: string;
|
|
441
|
+
/** Telegram parse mode. Defaults to HTML. */
|
|
442
|
+
parseMode?: "html" | "text";
|
|
443
|
+
}
|
|
444
|
+
|
|
416
445
|
export type ClientToGateway =
|
|
417
446
|
| RegisterMessage
|
|
418
447
|
| ToolCallMessage
|
|
@@ -428,4 +457,5 @@ export type ClientToGateway =
|
|
|
428
457
|
| RequestMs365ApprovalMessage
|
|
429
458
|
| RequestConfigApprovalMessage
|
|
430
459
|
| RequestConfigFinalizeMessage
|
|
431
|
-
| QuotaWallDetectedMessage
|
|
460
|
+
| QuotaWallDetectedMessage
|
|
461
|
+
| SendOutboundMessage;
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
GatewayToClient,
|
|
5
5
|
HeartbeatMessage,
|
|
6
6
|
InjectInboundMessage,
|
|
7
|
+
SendOutboundMessage,
|
|
7
8
|
QuotaWallDetectedMessage,
|
|
8
9
|
OperatorEventForward,
|
|
9
10
|
PermissionRequestForward,
|
|
@@ -45,6 +46,15 @@ export interface IpcServerOptions {
|
|
|
45
46
|
* inline scheduler simply ignore inject_inbound messages.
|
|
46
47
|
*/
|
|
47
48
|
onInjectInbound?: (client: IpcClient, msg: InjectInboundMessage) => void;
|
|
49
|
+
/**
|
|
50
|
+
* #2307 Tier-0 action tier — a model-free outbound post. Invoked when the
|
|
51
|
+
* agent-scheduler sibling fires a `kind: action` `telegram-message`. The
|
|
52
|
+
* handler is expected to post `msg.text` to `msg.chatId` (fenced to the
|
|
53
|
+
* agent's own chat) via the locked bot — with NO model, NO inject_inbound,
|
|
54
|
+
* NO session wake. Optional: gateways that don't run the inline scheduler
|
|
55
|
+
* ignore it.
|
|
56
|
+
*/
|
|
57
|
+
onSendOutbound?: (client: IpcClient, msg: SendOutboundMessage) => void;
|
|
48
58
|
/**
|
|
49
59
|
* The autoaccept-poll wedge-watchdog detected claude's `/rate-limit-options`
|
|
50
60
|
* weekly-quota menu (no 429 ever reached the gateway). Handler is expected to
|
|
@@ -246,6 +256,21 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
246
256
|
&& typeof inb.meta === "object"
|
|
247
257
|
&& inb.meta !== null;
|
|
248
258
|
}
|
|
259
|
+
case "send_outbound": {
|
|
260
|
+
// #2307 Tier-0 action tier — a model-free outbound post. Validate the
|
|
261
|
+
// wire shape; the gateway handler fences chatId to the agent's own chat.
|
|
262
|
+
if (typeof m.agentName !== "string"
|
|
263
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
264
|
+
if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
|
|
265
|
+
// text non-empty and bounded — Telegram caps a message at 4096 chars;
|
|
266
|
+
// reject over-long here (defense in depth against a malformed payload).
|
|
267
|
+
if (typeof m.text !== "string" || (m.text as string).length === 0
|
|
268
|
+
|| (m.text as string).length > 4096) return false;
|
|
269
|
+
if (m.threadId !== undefined
|
|
270
|
+
&& (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
|
|
271
|
+
if (m.parseMode !== undefined && m.parseMode !== "html" && m.parseMode !== "text") return false;
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
249
274
|
case "quota_wall_detected": {
|
|
250
275
|
// wedge-watchdog detected the /rate-limit-options weekly-quota menu.
|
|
251
276
|
if (typeof m.agentName !== "string"
|
|
@@ -335,6 +360,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
335
360
|
onOperatorEvent,
|
|
336
361
|
onPtyPartial,
|
|
337
362
|
onInjectInbound,
|
|
363
|
+
onSendOutbound,
|
|
338
364
|
onQuotaWallDetected,
|
|
339
365
|
onRequestDriveApproval,
|
|
340
366
|
onRequestMs365Approval,
|
|
@@ -444,6 +470,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
444
470
|
case "inject_inbound":
|
|
445
471
|
if (onInjectInbound) onInjectInbound(client, msg as InjectInboundMessage);
|
|
446
472
|
break;
|
|
473
|
+
case "send_outbound":
|
|
474
|
+
if (onSendOutbound) onSendOutbound(client, msg as SendOutboundMessage);
|
|
475
|
+
break;
|
|
447
476
|
case "quota_wall_detected":
|
|
448
477
|
if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
|
|
449
478
|
break;
|
|
@@ -33,6 +33,9 @@ export interface LinearActivityDeps {
|
|
|
33
33
|
fetchImpl?: typeof fetch
|
|
34
34
|
/** Agent slug (defaults to SWITCHROOM_AGENT_NAME). */
|
|
35
35
|
agent?: string
|
|
36
|
+
/** Default Linear team id for captured issues (multi-team workspaces);
|
|
37
|
+
* defaults to SWITCHROOM_LINEAR_DEFAULT_TEAM_ID. Tests inject directly. */
|
|
38
|
+
defaultTeamId?: string
|
|
36
39
|
/** Log sink — stderr in production. */
|
|
37
40
|
log?: (line: string) => void
|
|
38
41
|
}
|
|
@@ -158,3 +161,145 @@ export async function emitLinearAgentActivity(
|
|
|
158
161
|
log(`telegram gateway: linear_agent_activity: emitted type=${type} session=${sessionId} agent=${agent}\n`)
|
|
159
162
|
return { content: [{ type: 'text', text: `Linear ${type} emitted on session ${sessionId}` }] }
|
|
160
163
|
}
|
|
164
|
+
|
|
165
|
+
/** Hidden marker appended to a captured issue's description so a re-capture of
|
|
166
|
+
* the same Telegram message can be detected (dedup backstop; the gateway-side
|
|
167
|
+
* seen-set is the primary, race-free guard). */
|
|
168
|
+
export function captureDedupMarker(dedupKey: string): string {
|
|
169
|
+
return `\n\n<!-- switchroom-capture: ${dedupKey} -->`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create a Linear issue (capture-on-reaction → Linear). Mirrors
|
|
174
|
+
* emitLinearAgentActivity: validates, resolves the agent's Linear app token,
|
|
175
|
+
* POSTs `issueCreate`, returns an MCP text result; never throws on vault/
|
|
176
|
+
* network failure. The issue is filed AS the agent's app actor.
|
|
177
|
+
*
|
|
178
|
+
* Team: Linear requires a teamId. If `team_id` is omitted we auto-resolve —
|
|
179
|
+
* the zero-config single-team case uses the workspace's only team; a
|
|
180
|
+
* multi-team workspace returns actionable text asking for an explicit team_id.
|
|
181
|
+
*
|
|
182
|
+
* Dedup: when `dedup_key` is given we (a) best-effort search for a prior
|
|
183
|
+
* capture carrying the same marker and short-circuit to "already filed", and
|
|
184
|
+
* (b) embed the marker in the new issue's description as a durable backstop.
|
|
185
|
+
*/
|
|
186
|
+
export async function createLinearIssue(
|
|
187
|
+
args: Record<string, unknown>,
|
|
188
|
+
deps: LinearActivityDeps = {},
|
|
189
|
+
): Promise<ToolTextResult> {
|
|
190
|
+
const log = deps.log ?? ((s) => process.stderr.write(s))
|
|
191
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
192
|
+
|
|
193
|
+
const title = args.title as string | undefined
|
|
194
|
+
if (!title || title.trim() === '') throw new Error('linear_create_issue: title is required')
|
|
195
|
+
const body = (args.body as string | undefined) ?? ''
|
|
196
|
+
// Explicit team_id wins; otherwise the operator's configured default team
|
|
197
|
+
// (scaffold injects SWITCHROOM_LINEAR_DEFAULT_TEAM_ID for multi-team
|
|
198
|
+
// workspaces); otherwise we auto-resolve below (zero-config single team).
|
|
199
|
+
const teamIdArg =
|
|
200
|
+
(args.team_id as string | undefined) ??
|
|
201
|
+
(deps.defaultTeamId ?? process.env.SWITCHROOM_LINEAR_DEFAULT_TEAM_ID) ??
|
|
202
|
+
undefined
|
|
203
|
+
const dedupKey = (args.dedup_key as string | undefined) ?? undefined
|
|
204
|
+
const priority = typeof args.priority === 'number' ? (args.priority as number) : undefined
|
|
205
|
+
|
|
206
|
+
const agent = deps.agent ?? process.env.SWITCHROOM_AGENT_NAME ?? '-'
|
|
207
|
+
const resolveToken = deps.resolveToken ?? defaultResolveLinearToken
|
|
208
|
+
const tokenResult = await resolveToken(agent)
|
|
209
|
+
if (!tokenResult.ok) {
|
|
210
|
+
const hint =
|
|
211
|
+
tokenResult.reason === 'denied' || tokenResult.reason === 'not_found'
|
|
212
|
+
? ` Call vault_request_access for key 'linear/${agent}/token' (scope read), then retry.`
|
|
213
|
+
: ''
|
|
214
|
+
return {
|
|
215
|
+
content: [
|
|
216
|
+
{ type: 'text', text: `Couldn't file to Linear: no token (vault ${tokenResult.reason}).${hint}` },
|
|
217
|
+
],
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const token = tokenResult.token
|
|
221
|
+
|
|
222
|
+
const gql = async (query: string, variables: Record<string, unknown>): Promise<{ ok: true; data: any } | { ok: false; text: string }> => {
|
|
223
|
+
let resp: Response
|
|
224
|
+
try {
|
|
225
|
+
resp = await fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json', Authorization: token },
|
|
228
|
+
body: JSON.stringify({ query, variables }),
|
|
229
|
+
})
|
|
230
|
+
} catch (err) {
|
|
231
|
+
return { ok: false, text: `request error: ${(err as Error).message}` }
|
|
232
|
+
}
|
|
233
|
+
if (!resp.ok) {
|
|
234
|
+
const txt = await resp.text().catch(() => '')
|
|
235
|
+
return { ok: false, text: `Linear API ${resp.status}${txt ? ` — ${txt.slice(0, 200)}` : ''}` }
|
|
236
|
+
}
|
|
237
|
+
let json: { data?: any; errors?: Array<{ message?: string }> }
|
|
238
|
+
try {
|
|
239
|
+
json = (await resp.json()) as typeof json
|
|
240
|
+
} catch {
|
|
241
|
+
return { ok: false, text: 'malformed Linear API response' }
|
|
242
|
+
}
|
|
243
|
+
if (json.errors && json.errors.length > 0) {
|
|
244
|
+
return { ok: false, text: json.errors.map((e) => e.message ?? 'error').join('; ').slice(0, 300) }
|
|
245
|
+
}
|
|
246
|
+
return { ok: true, data: json.data }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Dedup backstop: search for a prior capture of the same Telegram message.
|
|
250
|
+
if (dedupKey) {
|
|
251
|
+
const search = await gql(
|
|
252
|
+
'query($term: String!) { searchIssues(term: $term) { nodes { id url title } } }',
|
|
253
|
+
{ term: dedupKey },
|
|
254
|
+
)
|
|
255
|
+
if (search.ok) {
|
|
256
|
+
const hit = (search.data?.searchIssues?.nodes ?? [])[0] as { url?: string } | undefined
|
|
257
|
+
if (hit?.url) {
|
|
258
|
+
log(`telegram gateway: linear_create_issue: dedup hit key=${dedupKey} agent=${agent}\n`)
|
|
259
|
+
return { content: [{ type: 'text', text: `Already filed: ${hit.url}` }] }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// a failed search is non-fatal — fall through to create (gateway seen-set is primary).
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Resolve the team.
|
|
266
|
+
let teamId = teamIdArg
|
|
267
|
+
if (!teamId) {
|
|
268
|
+
const teams = await gql('query { teams(first: 50) { nodes { id key name } } }', {})
|
|
269
|
+
if (!teams.ok) {
|
|
270
|
+
return { content: [{ type: 'text', text: `Couldn't file to Linear: ${teams.text}` }] }
|
|
271
|
+
}
|
|
272
|
+
const nodes = (teams.data?.teams?.nodes ?? []) as Array<{ id: string; key: string; name: string }>
|
|
273
|
+
if (nodes.length === 0) {
|
|
274
|
+
return { content: [{ type: 'text', text: 'Couldn\'t file to Linear: the workspace has no teams.' }] }
|
|
275
|
+
}
|
|
276
|
+
if (nodes.length > 1) {
|
|
277
|
+
const list = nodes.map((t) => `${t.key} (${t.name})`).join(', ')
|
|
278
|
+
return {
|
|
279
|
+
content: [
|
|
280
|
+
{ type: 'text', text: `Couldn't file to Linear: multiple teams (${list}) — set a default team (linear_agent.default_team_id) or pass team_id.` },
|
|
281
|
+
],
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
teamId = nodes[0].id
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const description = dedupKey ? `${body}${captureDedupMarker(dedupKey)}` : body
|
|
288
|
+
const input: Record<string, unknown> = { teamId, title, description }
|
|
289
|
+
if (priority !== undefined) input.priority = priority
|
|
290
|
+
|
|
291
|
+
const create = await gql(
|
|
292
|
+
'mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier url } } }',
|
|
293
|
+
{ input },
|
|
294
|
+
)
|
|
295
|
+
if (!create.ok) {
|
|
296
|
+
return { content: [{ type: 'text', text: `Couldn't file to Linear: ${create.text}` }] }
|
|
297
|
+
}
|
|
298
|
+
const issue = create.data?.issueCreate?.issue as { identifier?: string; url?: string } | undefined
|
|
299
|
+
if (create.data?.issueCreate?.success === false || !issue?.url) {
|
|
300
|
+
return { content: [{ type: 'text', text: 'Couldn\'t file to Linear: issue not created (success=false).' }] }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
log(`telegram gateway: linear_create_issue: filed ${issue.identifier} agent=${agent}${dedupKey ? ` dedup=${dedupKey}` : ''}\n`)
|
|
304
|
+
return { content: [{ type: 'text', text: `Filed: ${title} → ${issue.url}` }] }
|
|
305
|
+
}
|
|
@@ -137,6 +137,20 @@ export type RuntimeMetricEvent =
|
|
|
137
137
|
// executeReply scrub site. The two new sites close that gap.
|
|
138
138
|
site: 'reply' | 'edit_message' | 'progress_update' | 'answer_stream' | 'stream_reply' | 'turn_flush'
|
|
139
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* #2307 Tier-1: a cron fire routed to the `<agent>-cron` cheap session
|
|
142
|
+
* (meta.session=cron) fell back to the MAIN session because the cron bridge
|
|
143
|
+
* wasn't registered (wedged boot, crashed session, or hot-added cron with no
|
|
144
|
+
* live session yet). Each occurrence means the Tier-1 saving was NOT realised
|
|
145
|
+
* for that fire — a climbing counter is the runtime signal that a cron
|
|
146
|
+
* session is down (the doctor check catches a permanently-wedged one at boot;
|
|
147
|
+
* this catches a session that registered then died).
|
|
148
|
+
*/
|
|
149
|
+
| {
|
|
150
|
+
kind: 'cron_fell_back_to_main'
|
|
151
|
+
agent: string
|
|
152
|
+
prompt_key: string
|
|
153
|
+
}
|
|
140
154
|
|
|
141
155
|
/**
|
|
142
156
|
* The JSONL sink lives under the runtime state dir so it's per-agent
|