switchroom 0.15.12 → 0.15.13
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 +12 -1
- package/dist/auth-broker/index.js +12 -1
- package/dist/cli/notion-write-pretool.mjs +12 -1
- package/dist/cli/switchroom.js +69 -8
- package/dist/host-control/main.js +12 -1
- package/dist/vault/approvals/kernel-server.js +12 -1
- package/dist/vault/broker/server.js +12 -1
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +37 -0
- package/telegram-plugin/bridge/bridge.ts +31 -0
- package/telegram-plugin/dist/bridge/bridge.js +30 -0
- package/telegram-plugin/dist/gateway/gateway.js +434 -50
- package/telegram-plugin/dist/server.js +30 -0
- package/telegram-plugin/gateway/gateway.ts +123 -6
- package/telegram-plugin/gateway/linear-activity.ts +145 -0
- package/telegram-plugin/scoped-approval.ts +253 -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/scoped-approval.test.ts +254 -0
|
@@ -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 }));
|
|
@@ -420,6 +420,14 @@ import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.
|
|
|
420
420
|
import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
|
|
421
421
|
import { formatPermissionCardBody, describeGrant, naturalAction, formatPermissionResumeMessage } from '../permission-title.js'
|
|
422
422
|
import { resolveScopedAllowChoices, isRulePersisted } from '../permission-rule.js'
|
|
423
|
+
import {
|
|
424
|
+
type ScopedGrantStore,
|
|
425
|
+
scopedApprovalTtlMs,
|
|
426
|
+
resolveTimeBox,
|
|
427
|
+
recordScopedGrant,
|
|
428
|
+
lookupScopedGrant,
|
|
429
|
+
sweepScopedGrants,
|
|
430
|
+
} from '../scoped-approval.js'
|
|
423
431
|
import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
|
|
424
432
|
import {
|
|
425
433
|
readClaudeJsonOverage,
|
|
@@ -467,7 +475,7 @@ import {
|
|
|
467
475
|
listGrantsViaBroker,
|
|
468
476
|
revokeGrantViaBroker,
|
|
469
477
|
} from '../../src/vault/broker/client.js'
|
|
470
|
-
import { emitLinearAgentActivity } from './linear-activity.js'
|
|
478
|
+
import { emitLinearAgentActivity, createLinearIssue } from './linear-activity.js'
|
|
471
479
|
import {
|
|
472
480
|
approvalRequest,
|
|
473
481
|
approvalConsume,
|
|
@@ -3651,6 +3659,14 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
|
|
|
3651
3659
|
}
|
|
3652
3660
|
}
|
|
3653
3661
|
|
|
3662
|
+
// "⏱ 30 min" scoped-approval store (the middle tier between Allow-once and
|
|
3663
|
+
// 🔁 Always). Operator-tapped, gateway-side ONLY (never pushed to the
|
|
3664
|
+
// bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
|
|
3665
|
+
// agent name for per-agent isolation. All policy lives in
|
|
3666
|
+
// ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
|
|
3667
|
+
const scopedGrants: ScopedGrantStore = new Map()
|
|
3668
|
+
const selfAgentName = (): string => process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
3669
|
+
|
|
3654
3670
|
// `ask_user` MCP tool — open prompts awaiting a user button-tap.
|
|
3655
3671
|
// Keyed by askId (8 hex chars from generateAskId). Each entry holds
|
|
3656
3672
|
// the deferred promise that resolves the originating tool call, the
|
|
@@ -4246,6 +4262,9 @@ const pendingStateReaper = setInterval(() => {
|
|
|
4246
4262
|
for (const [k, v] of vaultPassphraseCache) {
|
|
4247
4263
|
if (now > v.expiresAt) vaultPassphraseCache.delete(k)
|
|
4248
4264
|
}
|
|
4265
|
+
// Drop expired "⏱ 30 min" scoped grants. (Lookup already fails closed on
|
|
4266
|
+
// expiry; this just keeps the map from accumulating dead entries.)
|
|
4267
|
+
sweepScopedGrants(scopedGrants, now)
|
|
4249
4268
|
for (const [k, v] of deferredSecrets) {
|
|
4250
4269
|
if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
|
|
4251
4270
|
}
|
|
@@ -5591,10 +5610,18 @@ const pendingPermissionBuffer = createPendingPermissionBuffer()
|
|
|
5591
5610
|
* rebuilds this row. callback_data stays tiny (verb + 5-char id) so we
|
|
5592
5611
|
* never approach Telegram's 64-byte ceiling.
|
|
5593
5612
|
*/
|
|
5594
|
-
function buildPermissionActionRow(
|
|
5613
|
+
function buildPermissionActionRow(
|
|
5614
|
+
requestId: string,
|
|
5615
|
+
showAlways: boolean,
|
|
5616
|
+
showTimeBox = false,
|
|
5617
|
+
): InlineKeyboard {
|
|
5595
5618
|
const kb = new InlineKeyboard()
|
|
5596
5619
|
.text('❌ Deny', `perm:deny:${requestId}`)
|
|
5597
5620
|
.text('✅ Allow once', `perm:allow:${requestId}`)
|
|
5621
|
+
// "⏱ 30 min" sits between once and always. Only shown for a narrow,
|
|
5622
|
+
// non-destructive scope (resolveTimeBox decides); broad/MCP/destructive
|
|
5623
|
+
// requests get once/always only.
|
|
5624
|
+
if (showTimeBox) kb.text('⏱ 30 min', `perm:tmb:${requestId}`)
|
|
5598
5625
|
if (showAlways) kb.text('🔁 Always…', `perm:always:${requestId}`)
|
|
5599
5626
|
return kb
|
|
5600
5627
|
}
|
|
@@ -5978,6 +6005,32 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5978
6005
|
|
|
5979
6006
|
onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
|
|
5980
6007
|
const { requestId, toolName, description, inputPreview } = msg
|
|
6008
|
+
// "⏱ 30 min" short-circuit: if the operator tapped a live scoped grant
|
|
6009
|
+
// covering this exact request, auto-allow without posting a card. CRITICAL:
|
|
6010
|
+
// dispatch WITHOUT a `rule` so the bridge does NOT cache it untimed
|
|
6011
|
+
// (sessionAllowRules has no TTL — a `rule` here would silently promote the
|
|
6012
|
+
// 30-min window to session-forever). The fixed window lives only in
|
|
6013
|
+
// scopedGrants. lookupScopedGrant fails closed on expiry and on a
|
|
6014
|
+
// destructive Bash command matching a family grant. No card, no pending
|
|
6015
|
+
// entry, no awaiting reaction — the turn just continues, silently.
|
|
6016
|
+
const scopedTtl = scopedApprovalTtlMs()
|
|
6017
|
+
if (scopedTtl > 0) {
|
|
6018
|
+
const hit = lookupScopedGrant(scopedGrants, selfAgentName(), toolName, inputPreview, Date.now())
|
|
6019
|
+
if (hit) {
|
|
6020
|
+
// Silent auto-allow — no card was posted and the turn was never parked
|
|
6021
|
+
// on the awaiting glyph, so we intentionally OMIT the resume-glyph flip
|
|
6022
|
+
// and the agent-voiced resume message (posting "got it, continuing" on
|
|
6023
|
+
// every auto-allowed call is the exact noise this tier removes). The
|
|
6024
|
+
// `no-card-verdict` sentinel below exempts this callsite from the
|
|
6025
|
+
// resume-guard pairing test.
|
|
6026
|
+
dispatchPermissionVerdict({ type: 'permission', requestId, behavior: 'allow' }) // no-card-verdict
|
|
6027
|
+
process.stderr.write(
|
|
6028
|
+
`telegram gateway: scoped-approval auto-allow tool=${toolName} rule="${hit}" ` +
|
|
6029
|
+
`request=${requestId} (time-boxed window)\n`,
|
|
6030
|
+
)
|
|
6031
|
+
return
|
|
6032
|
+
}
|
|
6033
|
+
}
|
|
5981
6034
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
|
|
5982
6035
|
// Natural-language card body — a plain sentence ("Gymbro wants to
|
|
5983
6036
|
// edit: supplement-log.md" + a why-line), never a raw tool id.
|
|
@@ -5996,8 +6049,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5996
6049
|
// any file ⚠️). The "🔁 Always…" button only appears when we can
|
|
5997
6050
|
// synthesize a meaningful rule for this tool; unknown tools get the
|
|
5998
6051
|
// two-button row only.
|
|
5999
|
-
const
|
|
6000
|
-
const
|
|
6052
|
+
const scopeChoices = resolveScopedAllowChoices(toolName, inputPreview)
|
|
6053
|
+
const showAlways = scopeChoices != null
|
|
6054
|
+
// Offer "⏱ 30 min" only for a narrow, non-destructive scope, and only
|
|
6055
|
+
// when the tier is enabled (TTL > 0).
|
|
6056
|
+
const showTimeBox = scopedApprovalTtlMs() > 0 &&
|
|
6057
|
+
resolveTimeBox(toolName, inputPreview, scopeChoices) != null
|
|
6058
|
+
const keyboard = buildPermissionActionRow(requestId, showAlways, showTimeBox)
|
|
6001
6059
|
// Route the card to the SAME place the post-verdict resume message
|
|
6002
6060
|
// lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
|
|
6003
6061
|
// there's an active turn — so a supergroup agent's card appears IN the
|
|
@@ -6720,6 +6778,7 @@ const ALLOWED_TOOLS = new Set([
|
|
|
6720
6778
|
'vault_request_access',
|
|
6721
6779
|
'request_secret',
|
|
6722
6780
|
'linear_agent_activity',
|
|
6781
|
+
'linear_create_issue',
|
|
6723
6782
|
])
|
|
6724
6783
|
|
|
6725
6784
|
async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
@@ -6767,6 +6826,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
|
|
|
6767
6826
|
return executeRequestSecret(args)
|
|
6768
6827
|
case 'linear_agent_activity':
|
|
6769
6828
|
return executeLinearAgentActivity(args)
|
|
6829
|
+
case 'linear_create_issue':
|
|
6830
|
+
return executeLinearCreateIssue(args)
|
|
6770
6831
|
default:
|
|
6771
6832
|
throw new Error(`unknown tool: ${tool}`)
|
|
6772
6833
|
}
|
|
@@ -6802,6 +6863,10 @@ async function executeLinearAgentActivity(args: Record<string, unknown>): Promis
|
|
|
6802
6863
|
return emitLinearAgentActivity(args)
|
|
6803
6864
|
}
|
|
6804
6865
|
|
|
6866
|
+
async function executeLinearCreateIssue(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
6867
|
+
return createLinearIssue(args)
|
|
6868
|
+
}
|
|
6869
|
+
|
|
6805
6870
|
async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
6806
6871
|
const chat_id = args.chat_id as string
|
|
6807
6872
|
if (!chat_id) throw new Error('update_checklist: chat_id is required')
|
|
@@ -18962,7 +19027,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
18962
19027
|
}
|
|
18963
19028
|
|
|
18964
19029
|
// Permission request buttons.
|
|
18965
|
-
const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data)
|
|
19030
|
+
const m = /^perm:(allow|deny|always|asn|asb|back|tmb):([a-km-z]{5})$/.exec(data)
|
|
18966
19031
|
if (!m) { await ctx.answerCallbackQuery().catch(() => {}); return }
|
|
18967
19032
|
const access = loadAccess()
|
|
18968
19033
|
const senderId = String(ctx.from.id)
|
|
@@ -18977,7 +19042,10 @@ bot.on('callback_query:data', async ctx => {
|
|
|
18977
19042
|
if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
|
|
18978
19043
|
let keyboard: InlineKeyboard
|
|
18979
19044
|
if (behavior === 'back') {
|
|
18980
|
-
|
|
19045
|
+
const backChoices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
19046
|
+
const backTimeBox = scopedApprovalTtlMs() > 0 &&
|
|
19047
|
+
resolveTimeBox(details.tool_name, details.input_preview, backChoices) != null
|
|
19048
|
+
keyboard = buildPermissionActionRow(request_id, true, backTimeBox)
|
|
18981
19049
|
} else {
|
|
18982
19050
|
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
18983
19051
|
if (choices == null) {
|
|
@@ -19201,6 +19269,55 @@ bot.on('callback_query:data', async ctx => {
|
|
|
19201
19269
|
return
|
|
19202
19270
|
}
|
|
19203
19271
|
|
|
19272
|
+
// "⏱ 30 min" — record a fixed-window scoped grant for the NARROW scope,
|
|
19273
|
+
// then allow the in-flight call. Mirrors the asn/asb structure: dispatch
|
|
19274
|
+
// the verdict immediately, then edit the card. CRITICAL: the verdict
|
|
19275
|
+
// carries NO `rule` (unlike asn/asb), so the bridge does not cache it
|
|
19276
|
+
// untimed — the window lives only in scopedGrants, gateway-side.
|
|
19277
|
+
if (behavior === 'tmb') {
|
|
19278
|
+
const details = pendingPermissions.get(request_id)
|
|
19279
|
+
if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
|
|
19280
|
+
const ttl = scopedApprovalTtlMs()
|
|
19281
|
+
if (ttl <= 0) { await ctx.answerCallbackQuery({ text: 'Time-boxed approvals are disabled.' }).catch(() => {}); return }
|
|
19282
|
+
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
19283
|
+
const tb = resolveTimeBox(details.tool_name, details.input_preview, choices)
|
|
19284
|
+
if (!tb) { await ctx.answerCallbackQuery({ text: 'This action can\'t be time-boxed.' }).catch(() => {}); return }
|
|
19285
|
+
const agentName = selfAgentName()
|
|
19286
|
+
if (!agentName) { await ctx.answerCallbackQuery({ text: 'Time-box needs SWITCHROOM_AGENT_NAME — gateway is misconfigured.' }).catch(() => {}); return }
|
|
19287
|
+
|
|
19288
|
+
pendingPermissions.delete(request_id)
|
|
19289
|
+
// (1) Allow the in-flight call NOW — no `rule` (keeps the window
|
|
19290
|
+
// strictly gateway-side; the bridge must not cache it untimed).
|
|
19291
|
+
dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior: 'allow' })
|
|
19292
|
+
// (2) Record the fixed-window grant so matching calls auto-allow.
|
|
19293
|
+
recordScopedGrant(scopedGrants, agentName, tb.rule, Date.now(), ttl)
|
|
19294
|
+
resumeReactionAfterVerdict()
|
|
19295
|
+
postPermissionResumeMessage({
|
|
19296
|
+
behavior: 'allow',
|
|
19297
|
+
action: naturalAction(details.tool_name, details.input_preview),
|
|
19298
|
+
})
|
|
19299
|
+
process.stderr.write(
|
|
19300
|
+
`telegram gateway: scoped-approval granted rule="${tb.rule}" agent=${agentName} ` +
|
|
19301
|
+
`ttl_ms=${ttl} (request_id=${request_id})\n`,
|
|
19302
|
+
)
|
|
19303
|
+
|
|
19304
|
+
const mins = Math.max(1, Math.round(ttl / 60_000))
|
|
19305
|
+
// Honest card: state the real BREADTH (e.g. "any `git` command"), not
|
|
19306
|
+
// just the rule, plus the window — consent covers both (access-model
|
|
19307
|
+
// honest-card contract).
|
|
19308
|
+
const sourceMsg = ctx.callbackQuery?.message
|
|
19309
|
+
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
19310
|
+
? escapeHtmlForTg(sourceMsg.text)
|
|
19311
|
+
: ''
|
|
19312
|
+
const editLabel = `⏱ <b>Allowed for ${mins} min — ${escapeHtmlForTg(tb.breadth)}</b> · re-asks after that, and now for anything else`
|
|
19313
|
+
await finalizeCallback(ctx, {
|
|
19314
|
+
ackText: `⏱ Allowed for ${mins} min`.slice(0, 200),
|
|
19315
|
+
newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
|
|
19316
|
+
parseMode: 'HTML',
|
|
19317
|
+
})
|
|
19318
|
+
return
|
|
19319
|
+
}
|
|
19320
|
+
|
|
19204
19321
|
// Forward permission decision to connected bridges. Capture the work
|
|
19205
19322
|
// phrase BEFORE deleting the pending entry — postPermissionResumeMessage
|
|
19206
19323
|
// (fired in synthInbound below) names the resumed work.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped, time-boxed approval — the "⏱ 30 min" tier, the middle rung
|
|
3
|
+
* between "✅ Allow once" (re-prompts on the very next call) and
|
|
4
|
+
* "🔁 Always…" (a durable `tools.allow` write that lasts forever). After
|
|
5
|
+
* the operator taps "⏱ 30 min" on a permission card, byte-identical
|
|
6
|
+
* in-scope requests auto-allow for a fixed window without re-carding.
|
|
7
|
+
*
|
|
8
|
+
* Design contract (reference/access-model.md — "you hold the leash"):
|
|
9
|
+
*
|
|
10
|
+
* - **Operator-authored only.** Every cache entry is created by an
|
|
11
|
+
* `allowFrom`-authenticated Telegram tap. No tool call can seed an
|
|
12
|
+
* entry (first contact always cards) or extend one (the window is a
|
|
13
|
+
* FIXED box — `recordScopedGrant` sets `expiresAt` once; a matching
|
|
14
|
+
* request never moves it). So an agent can never author its own
|
|
15
|
+
* authorization.
|
|
16
|
+
* - **Gateway-side only.** This store lives in the gateway process. It
|
|
17
|
+
* must NOT be pushed down to the bridge's `sessionAllowRules`
|
|
18
|
+
* (`bridge.ts`) — that cache is agent-uid, untimed, and would silently
|
|
19
|
+
* promote a 30-min grant to session-forever. The gateway dispatches
|
|
20
|
+
* the in-flight allow WITHOUT the `rule` field for exactly this reason.
|
|
21
|
+
* - **Conservative scope (this tier, v1).** Only the *narrow* scope is
|
|
22
|
+
* ever time-boxed: an exact file path (`Edit(/x.ts)`) or a Bash
|
|
23
|
+
* command-family (`Bash(git:*)`). Broad scopes ("any file", resource-
|
|
24
|
+
* blind MCP, "any command") are NOT offered the ⏱ button — they stay
|
|
25
|
+
* once / always. This covers the real fatigue (re-editing the same
|
|
26
|
+
* file, re-running a safe command) without fanning one tap across an
|
|
27
|
+
* unbounded action set.
|
|
28
|
+
* - **Fail-closed on irreversible.** A Bash family grant (`Bash(git:*)`)
|
|
29
|
+
* must never auto-allow a destructive member of that family
|
|
30
|
+
* (`git push --force`, `git reset --hard`). `isDestructiveBashCommand`
|
|
31
|
+
* is re-checked at BOTH grant time (don't offer ⏱) and match time
|
|
32
|
+
* (a cached family grant fails closed → re-cards) so per-call consent
|
|
33
|
+
* for irreversible actions is preserved.
|
|
34
|
+
*
|
|
35
|
+
* Pure + side-effect-free so it unit-tests under vitest AND bun without a
|
|
36
|
+
* Grammy context (mirrors permission-rule.ts). Callers pass `now`/`ttlMs`
|
|
37
|
+
* explicitly; nothing reads the clock here.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { basename } from "node:path";
|
|
41
|
+
import { matchesAllowRule, type ScopedAllowChoices } from "./permission-rule.js";
|
|
42
|
+
|
|
43
|
+
/** Default time-box window: 30 minutes. */
|
|
44
|
+
export const SCOPED_APPROVAL_DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the configured window from the environment. `0` (or negative)
|
|
48
|
+
* disables the tier — the gateway hides the ⏱ button and never
|
|
49
|
+
* short-circuits. A blank/garbage value falls back to the 30-min default.
|
|
50
|
+
* Kill-switch: `SWITCHROOM_SCOPED_APPROVAL_TTL_MS=0`.
|
|
51
|
+
*/
|
|
52
|
+
export function scopedApprovalTtlMs(
|
|
53
|
+
env: Record<string, string | undefined> = process.env,
|
|
54
|
+
): number {
|
|
55
|
+
const raw = env.SWITCHROOM_SCOPED_APPROVAL_TTL_MS;
|
|
56
|
+
if (raw === undefined || raw.trim() === "") return SCOPED_APPROVAL_DEFAULT_TTL_MS;
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
if (!Number.isFinite(n) || n < 0) return SCOPED_APPROVAL_DEFAULT_TTL_MS;
|
|
59
|
+
return Math.floor(n);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A single operator-tapped, time-boxed grant. */
|
|
63
|
+
export interface ScopedGrant {
|
|
64
|
+
/** Narrow allow-rule, e.g. `Edit(/state/x.ts)` or `Bash(git:*)`. */
|
|
65
|
+
readonly rule: string;
|
|
66
|
+
/** Unix-ms when this grant stops auto-allowing. Fixed at grant time. */
|
|
67
|
+
readonly expiresAt: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Per-agent store of live time-boxed grants. Keyed by agent name. */
|
|
71
|
+
export type ScopedGrantStore = Map<string, ScopedGrant[]>;
|
|
72
|
+
|
|
73
|
+
/** The narrow rule + an honest operator-facing breadth phrase for the card. */
|
|
74
|
+
export interface TimeBoxDecision {
|
|
75
|
+
readonly rule: string;
|
|
76
|
+
/** e.g. "edits to x.ts" / "any `git` command" — states the real breadth. */
|
|
77
|
+
readonly breadth: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const FILE_RULE = /^(Edit|Write|MultiEdit|NotebookEdit|Read)\((.+)\)$/;
|
|
81
|
+
const BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Conservative time-box eligibility. Given the already-resolved scope
|
|
85
|
+
* choices for a permission request, return the NARROW rule to time-box
|
|
86
|
+
* plus an honest breadth phrase — or `null` when this request must not
|
|
87
|
+
* get a ⏱ button (broad-only tools, MCP, Skill, a destructive Bash
|
|
88
|
+
* command, or any tool with no narrow sub-scope).
|
|
89
|
+
*
|
|
90
|
+
* - File tools with an exact path → time-boxable (bounded to the one
|
|
91
|
+
* operator-seen file).
|
|
92
|
+
* - Bash with a first-token family → time-boxable ONLY when the
|
|
93
|
+
* triggering command itself is non-destructive. The family grant
|
|
94
|
+
* still covers the whole `<tok>` family for matching, but match-time
|
|
95
|
+
* re-checks each later command (see `lookupScopedGrant`).
|
|
96
|
+
*/
|
|
97
|
+
export function resolveTimeBox(
|
|
98
|
+
toolName: string,
|
|
99
|
+
inputPreview: string | undefined,
|
|
100
|
+
choices: ScopedAllowChoices | null,
|
|
101
|
+
): TimeBoxDecision | null {
|
|
102
|
+
// Only ever time-box the narrow scope, and only when one exists.
|
|
103
|
+
const specific = choices?.specific;
|
|
104
|
+
if (!specific || specific.broad) return null;
|
|
105
|
+
const rule = specific.rule;
|
|
106
|
+
|
|
107
|
+
const fileMatch = FILE_RULE.exec(rule);
|
|
108
|
+
if (fileMatch) {
|
|
109
|
+
const verb = fileMatch[1] === "Read" ? "reads of" : "edits to";
|
|
110
|
+
return { rule, breadth: `${verb} ${basename(fileMatch[2]!)}` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const bashMatch = BASH_FAMILY_RULE.exec(rule);
|
|
114
|
+
if (bashMatch) {
|
|
115
|
+
const cmd = readBashCommand(inputPreview);
|
|
116
|
+
// No command text to vet, or a destructive triggering command → don't
|
|
117
|
+
// offer the time-box at all (fall through to once / always).
|
|
118
|
+
if (!cmd || isDestructiveBashCommand(cmd)) return null;
|
|
119
|
+
return { rule, breadth: `any \`${bashMatch[1]}\` command` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// MCP (resource-blind), Skill, broad-only tools → not time-boxed in the
|
|
123
|
+
// conservative tier.
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Record an operator-tapped 30-min grant. The window is a FIXED box: a
|
|
129
|
+
* re-tap on the same rule resets it (the operator just re-approved), but
|
|
130
|
+
* nothing else ever moves `expiresAt`. Re-tapping the same rule replaces
|
|
131
|
+
* the prior entry rather than accumulating duplicates. A non-positive
|
|
132
|
+
* `ttlMs` is a no-op (tier disabled).
|
|
133
|
+
*/
|
|
134
|
+
export function recordScopedGrant(
|
|
135
|
+
store: ScopedGrantStore,
|
|
136
|
+
agent: string,
|
|
137
|
+
rule: string,
|
|
138
|
+
now: number,
|
|
139
|
+
ttlMs: number,
|
|
140
|
+
): void {
|
|
141
|
+
if (ttlMs <= 0) return;
|
|
142
|
+
const list = store.get(agent) ?? [];
|
|
143
|
+
const others = list.filter((g) => g.rule !== rule);
|
|
144
|
+
others.push({ rule, expiresAt: now + ttlMs });
|
|
145
|
+
store.set(agent, others);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Is there a LIVE operator-tapped grant covering this request? Returns the
|
|
150
|
+
* matching rule (for the audit log line) or `null`.
|
|
151
|
+
*
|
|
152
|
+
* FAIL-CLOSED in two ways:
|
|
153
|
+
* 1. an expired grant never matches (→ re-card);
|
|
154
|
+
* 2. a destructive Bash command never auto-allows even when its family
|
|
155
|
+
* grant (`Bash(git:*)`) matches — `git push --force` re-cards even
|
|
156
|
+
* after `git status` was time-boxed.
|
|
157
|
+
*
|
|
158
|
+
* Never mutates the store (no window-sliding). Pure read.
|
|
159
|
+
*/
|
|
160
|
+
export function lookupScopedGrant(
|
|
161
|
+
store: ScopedGrantStore,
|
|
162
|
+
agent: string,
|
|
163
|
+
toolName: string,
|
|
164
|
+
inputPreview: string | undefined,
|
|
165
|
+
now: number,
|
|
166
|
+
): string | null {
|
|
167
|
+
const list = store.get(agent);
|
|
168
|
+
if (!list || list.length === 0) return null;
|
|
169
|
+
for (const g of list) {
|
|
170
|
+
if (g.expiresAt <= now) continue; // expired → fail closed
|
|
171
|
+
if (!matchesAllowRule(g.rule, toolName, inputPreview)) continue;
|
|
172
|
+
if (toolName === "Bash") {
|
|
173
|
+
const cmd = readBashCommand(inputPreview);
|
|
174
|
+
// Family grant matched, but re-vet THIS command: destructive or
|
|
175
|
+
// un-vettable → fail closed, re-card.
|
|
176
|
+
if (!cmd || isDestructiveBashCommand(cmd)) return null;
|
|
177
|
+
}
|
|
178
|
+
return g.rule;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Drop expired grants. Call from the gateway's periodic sweep. */
|
|
184
|
+
export function sweepScopedGrants(store: ScopedGrantStore, now: number): void {
|
|
185
|
+
for (const [agent, list] of store) {
|
|
186
|
+
const live = list.filter((g) => g.expiresAt > now);
|
|
187
|
+
if (live.length === 0) store.delete(agent);
|
|
188
|
+
else if (live.length !== list.length) store.set(agent, live);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Heuristic destructive/irreversible-command detector. FAIL-CLOSED: when
|
|
194
|
+
* a command can't be read or looks risky, return `true` so it is never
|
|
195
|
+
* time-boxed (it re-prompts instead). Better to re-card a safe command
|
|
196
|
+
* than auto-allow a destructive one. Covers the access-model crown-jewel
|
|
197
|
+
* cases that ride the tool-permission channel: pipe-to-shell, privilege
|
|
198
|
+
* escalation, destructive coreutils/disk ops, recursive perms, device
|
|
199
|
+
* redirects, destructive git, power/process control, and bulk
|
|
200
|
+
* docker/package teardown.
|
|
201
|
+
*/
|
|
202
|
+
export function isDestructiveBashCommand(command: string): boolean {
|
|
203
|
+
if (!command || !command.trim()) return true;
|
|
204
|
+
const c = command.toLowerCase();
|
|
205
|
+
|
|
206
|
+
// Backtick command substitution is un-vettable: the destructive op can
|
|
207
|
+
// hide inside `…` where the command-position anchors below (which include
|
|
208
|
+
// `$(` but not the backtick) would miss it — e.g. `git status \`rm -rf x\``
|
|
209
|
+
// matches the `Bash(git:*)` family but its first token is the harmless
|
|
210
|
+
// `git`. There is no need to time-box a backtick-substituted command, so
|
|
211
|
+
// fail closed (re-card). `$(…)` substitution stays handled by the `(`
|
|
212
|
+
// anchor in the rules below.
|
|
213
|
+
if (c.includes("`")) return true;
|
|
214
|
+
|
|
215
|
+
// download-and-execute: ... | sh|bash|python|node
|
|
216
|
+
if (/\|\s*(sudo\s+)?(sh|bash|zsh|fish|python\d?|perl|ruby|node)\b/.test(c)) return true;
|
|
217
|
+
// privilege escalation
|
|
218
|
+
if (/(^|\s|;|&&|\|\||\()sudo\b/.test(c)) return true;
|
|
219
|
+
if (/(^|\s|;|&&|\|\||\()(su|doas)\s/.test(c)) return true;
|
|
220
|
+
// destructive coreutils / disk
|
|
221
|
+
if (/(^|\s|;|&&|\|\||\()(rm|rmdir|dd|shred|truncate|fdisk|mkfs\S*|wipefs|blkdiscard|fallocate)\b/.test(c)) return true;
|
|
222
|
+
// recursive permission / ownership changes (-R / --recursive)
|
|
223
|
+
if (/\b(chmod|chown|chgrp)\b[^|;&]*(\s-(-recursive|[a-z]*r[a-z]*)\b)/.test(c)) return true;
|
|
224
|
+
// redirection clobbering devices or system dirs
|
|
225
|
+
if (/>\s*\/(dev|etc|boot|sys|proc)\b/.test(c)) return true;
|
|
226
|
+
// destructive git
|
|
227
|
+
if (/\bgit\b/.test(c) &&
|
|
228
|
+
/(push\b[^|;&]*(--force|-f\b|--force-with-lease)|push\s+[^\s]*\s+\+|reset\s+--hard|clean\s+-[a-z]*[fd]|filter-branch|reflog\s+expire|update-ref\s+-d|branch\s+-d{1,2}\b|checkout\s+--\s|restore\b)/.test(c)) return true;
|
|
229
|
+
// power / process control
|
|
230
|
+
if (/(^|\s|;|&&|\|\||\()(shutdown|reboot|halt|poweroff|kill|killall|pkill)\b/.test(c)) return true;
|
|
231
|
+
if (/(^|\s)init\s+0\b/.test(c)) return true;
|
|
232
|
+
// bulk docker teardown / package removal
|
|
233
|
+
if (/\bdocker\b[^|;&]*\b(rm|prune)\b/.test(c)) return true;
|
|
234
|
+
if (/(^|\s)(apt|apt-get|yum|dnf|brew|pacman|npm|pnpm|yarn|pip\d?)\b[^|;&]*\b(remove|uninstall|purge|prune)\b/.test(c)) return true;
|
|
235
|
+
// fork bomb
|
|
236
|
+
if (/:\s*\(\s*\)\s*\{/.test(c)) return true;
|
|
237
|
+
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Extract the `command` string from a permission_request input preview. */
|
|
242
|
+
function readBashCommand(inputPreview: string | undefined): string | null {
|
|
243
|
+
if (!inputPreview || typeof inputPreview !== "string") return null;
|
|
244
|
+
const trimmed = inputPreview.trim();
|
|
245
|
+
if (!trimmed.startsWith("{")) return null;
|
|
246
|
+
try {
|
|
247
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
248
|
+
const cmd = parsed?.command;
|
|
249
|
+
return typeof cmd === "string" && cmd.length > 0 ? cmd : null;
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -51,7 +51,7 @@ describe('linear_agent_activity — gateway wiring (#2298)', () => {
|
|
|
51
51
|
})
|
|
52
52
|
|
|
53
53
|
it('is allow-listed and dispatched', () => {
|
|
54
|
-
expect(gw).toMatch(/'linear_agent_activity'
|
|
54
|
+
expect(gw).toMatch(/'linear_agent_activity',/)
|
|
55
55
|
expect(gw).toMatch(/case 'linear_agent_activity':\s*\n\s*return executeLinearAgentActivity\(args\)/)
|
|
56
56
|
})
|
|
57
57
|
})
|