create-loopengine 0.1.52 → 0.1.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -101,8 +101,19 @@ import {
101
101
  verifyProxyIdentity,
102
102
  TRUSTED_PROXY_IDENTITY_HEADER,
103
103
  NotifierFileError,
104
+ verifyDecisionToken,
105
+ renderDecidePage,
106
+ renderDecideGonePage,
107
+ renderDecideInvalidPage,
104
108
  describeAgentNotifier,
105
109
  listAgentPending,
110
+ listAgentSlackChannels,
111
+ slackCredentialsFor,
112
+ openSlackAnswerModal,
113
+ parseSlackInteraction,
114
+ replyInSlack,
115
+ slackOutcomeText,
116
+ verifySlackRequest,
106
117
  removeAgentNotifier,
107
118
  saveAgentNotifier,
108
119
  testAgentNotifier,
@@ -235,6 +246,15 @@ function resolveTenant(
235
246
  return result === undefined ? { ok: false } : { ok: true, value: result }
236
247
  }
237
248
 
249
+ function readRawBody(req: IncomingMessage): Promise<string> {
250
+ return new Promise((resolve, reject) => {
251
+ let data = ''
252
+ req.on('data', (chunk) => (data += chunk))
253
+ req.on('end', () => resolve(data))
254
+ req.on('error', reject)
255
+ })
256
+ }
257
+
238
258
  function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
239
259
  return new Promise((resolve, reject) => {
240
260
  let data = ''
@@ -1045,7 +1065,7 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
1045
1065
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(listDeclaredEnvVars(agentName)))
1046
1066
  }
1047
1067
 
1048
- // Backs the console's Permissions → Approvals section — see
1068
+ // Backs the console's Permissions tab (where approval requests and questions are sent) — see
1049
1069
  // web/notifier-admin.ts. Reading is open like the rest of the admin
1050
1070
  // surface; saving or removing writes a secret and a file, so it gets the
1051
1071
  // same refuse-without-auth gate as the env routes below.
@@ -1055,7 +1075,7 @@ function handleNotifierGet(res: ServerResponse, agentName: string): void {
1055
1075
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1056
1076
  return
1057
1077
  }
1058
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeAgentNotifier(entry.config, defaultWebhook)))
1078
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1059
1079
  }
1060
1080
 
1061
1081
  async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
@@ -1077,7 +1097,7 @@ async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agen
1077
1097
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1078
1098
  return
1079
1099
  }
1080
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeAgentNotifier(entry.config, defaultWebhook)))
1100
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1081
1101
  }
1082
1102
 
1083
1103
  function handleNotifierDelete(res: ServerResponse, agentName: string): void {
@@ -1093,7 +1113,7 @@ function handleNotifierDelete(res: ServerResponse, agentName: string): void {
1093
1113
  return
1094
1114
  }
1095
1115
  removeAgentNotifier(entry.config)
1096
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeAgentNotifier(entry.config, defaultWebhook)))
1116
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1097
1117
  }
1098
1118
 
1099
1119
  async function handleNotifierTest(res: ServerResponse, agentName: string): Promise<void> {
@@ -1105,6 +1125,23 @@ async function handleNotifierTest(res: ServerResponse, agentName: string): Promi
1105
1125
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook)))
1106
1126
  }
1107
1127
 
1128
+ // Backs the console's Slack channel picker. A bot token typed into the form
1129
+ // (not saved yet) can be sent in the body; otherwise the saved one is used.
1130
+ async function handleSlackChannels(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1131
+ const entry = getEntry(agentName)
1132
+ if (!entry) {
1133
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1134
+ return
1135
+ }
1136
+ const body = await readJsonBody(req)
1137
+ try {
1138
+ const channels = await listAgentSlackChannels(entry.config, typeof body.botToken === 'string' ? body.botToken : undefined)
1139
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ channels }))
1140
+ } catch (err) {
1141
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1142
+ }
1143
+ }
1144
+
1108
1145
  // Every approval/question still waiting for this agent — for an app that
1109
1146
  // can't receive webhooks (it polls this instead) and for the console.
1110
1147
  async function handlePendingGet(res: ServerResponse, agentName: string): Promise<void> {
@@ -2039,7 +2076,11 @@ async function createCheckpointFromPending(
2039
2076
  * response here is authoritative on its own, not a fallback for some
2040
2077
  * other channel. */
2041
2078
  async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
2042
- const body = await readJsonBody(req)
2079
+ await resolvePendingApproval(res, pendingId, await readJsonBody(req))
2080
+ }
2081
+
2082
+ // The resolve itself, shared with the decision page's POST /decide/<token>.
2083
+ async function resolvePendingApproval(res: ServerResponse, pendingId: string, body: Record<string, unknown>): Promise<void> {
2043
2084
  const decision = body.decision === 'approve' || body.decision === 'deny' ? body.decision : undefined
2044
2085
  if (!decision) {
2045
2086
  res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'approve' or 'deny'" }))
@@ -2151,7 +2192,10 @@ async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerRes
2151
2192
  * either answered or it isn't. Shares the same "outstanding empty →
2152
2193
  * resume, else report remaining count" tail via respondAfterResolution. */
2153
2194
  async function handlePendingQuestionAnswer(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
2154
- const body = await readJsonBody(req)
2195
+ await answerPendingQuestion(res, pendingId, await readJsonBody(req))
2196
+ }
2197
+
2198
+ async function answerPendingQuestion(res: ServerResponse, pendingId: string, body: Record<string, unknown>): Promise<void> {
2155
2199
  if (typeof body.answer !== 'string') {
2156
2200
  res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "'answer' must be a string" }))
2157
2201
  return
@@ -2437,7 +2481,134 @@ function handleActivityGet(res: ServerResponse): void {
2437
2481
  )
2438
2482
  }
2439
2483
 
2484
+ // A decision link (loopengine's core/approval-links.ts): GET shows the decision page,
2485
+ // POST makes the decision. The signed token is the credential, so these
2486
+ // sit outside the admin login on purpose — the link is meant for
2487
+ // whoever gets the notification, not only admins.
2488
+ async function handleDecide(req: IncomingMessage, res: ServerResponse, rawToken: string): Promise<void> {
2489
+ const html = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }
2490
+ const json = { 'content-type': 'application/json', 'cache-control': 'no-store' }
2491
+ const token = verifyDecisionToken(rawToken)
2492
+ if (!token) {
2493
+ if (req.method === 'GET') res.writeHead(404, html).end(renderDecideInvalidPage())
2494
+ else res.writeHead(400, json).end(JSON.stringify({ error: 'This link is not valid or has expired.' }))
2495
+ return
2496
+ }
2497
+ const checkpoint = await checkpoints.get(token.pendingId)
2498
+ const item = checkpoint?.outstanding[token.pendingId]
2499
+ if (!checkpoint || !item || (item.kind ?? 'approval') !== token.kind) {
2500
+ if (req.method === 'GET') res.writeHead(410, html).end(renderDecideGonePage())
2501
+ else res.writeHead(410, json).end(JSON.stringify({ error: 'This decision has already been made.' }))
2502
+ return
2503
+ }
2504
+ if (req.method === 'GET') {
2505
+ res.writeHead(200, html).end(
2506
+ renderDecidePage({
2507
+ kind: token.kind,
2508
+ agent: checkpoint.agent,
2509
+ tool: item.tool,
2510
+ args: item.args,
2511
+ reason: item.reason,
2512
+ sessionId: checkpoint.sessionId,
2513
+ tenant: checkpoint.tenant,
2514
+ createdAt: checkpoint.createdAt,
2515
+ expiresAt: token.expiresAt,
2516
+ }),
2517
+ )
2518
+ return
2519
+ }
2520
+ const body = await readJsonBody(req)
2521
+ if (token.kind === 'question') await answerPendingQuestion(res, token.pendingId, { answer: body.answer })
2522
+ else await resolvePendingApproval(res, token.pendingId, { decision: body.decision, editedArgs: body.editedArgs })
2523
+ }
2524
+
2525
+ // Runs one of the resolve/answer handlers above without a real HTTP
2526
+ // response to write to — for the Slack endpoint below, which has already
2527
+ // answered Slack and reports the outcome back into the Slack message.
2528
+ function captureResponse(): { res: ServerResponse; done: Promise<{ status: number; body: Record<string, unknown> }> } {
2529
+ let resolveDone: (value: { status: number; body: Record<string, unknown> }) => void
2530
+ const done = new Promise<{ status: number; body: Record<string, unknown> }>((resolve) => (resolveDone = resolve))
2531
+ let status = 200
2532
+ const res = {
2533
+ headersSent: false,
2534
+ writeHead(code: number) {
2535
+ status = code
2536
+ return res
2537
+ },
2538
+ end(chunk?: string) {
2539
+ let body: Record<string, unknown> = {}
2540
+ try {
2541
+ body = chunk ? (JSON.parse(chunk) as Record<string, unknown>) : {}
2542
+ } catch {}
2543
+ resolveDone({ status, body })
2544
+ return res
2545
+ },
2546
+ }
2547
+ return { res: res as unknown as ServerResponse, done }
2548
+ }
2549
+
2550
+ // A Slack app's Interactivity Request URL (core/slack-interactions.ts):
2551
+ // Approve/Deny, an answer option, or the "Other…" modal, from a Slack
2552
+ // notifier's message. Outside the admin login on purpose — Slack can't
2553
+ // send it — and verified with that agent's Slack signing secret instead.
2554
+ // Slack wants an answer within three seconds, so the decision is acked
2555
+ // right away and its outcome posted back into the message afterwards.
2556
+ async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
2557
+ const rawBody = await readRawBody(req)
2558
+ const entry = getEntry(agentName)
2559
+ const { botToken, signingSecret } = entry ? slackCredentialsFor(entry.config) : {}
2560
+ if (!entry || !signingSecret) {
2561
+ res.writeHead(404, { 'content-type': 'text/plain' }).end('No Slack signing secret is set for this agent.')
2562
+ return
2563
+ }
2564
+ const timestamp = req.headers['x-slack-request-timestamp']
2565
+ const signature = req.headers['x-slack-signature']
2566
+ if (!verifySlackRequest(rawBody, typeof timestamp === 'string' ? timestamp : undefined, typeof signature === 'string' ? signature : undefined, signingSecret)) {
2567
+ res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid Slack signature.')
2568
+ return
2569
+ }
2570
+ const interaction = parseSlackInteraction(rawBody)
2571
+ if (interaction.type === 'open_answer') {
2572
+ if (botToken) await openSlackAnswerModal(botToken, interaction.triggerId, interaction.pendingId).catch((err) => console.error('[loopengine] Slack modal failed:', err))
2573
+ res.writeHead(200).end()
2574
+ return
2575
+ }
2576
+ res.writeHead(200).end()
2577
+ if (interaction.type === 'ignore') return
2578
+
2579
+ const capture = captureResponse()
2580
+ if (interaction.type === 'decide') await resolvePendingApproval(capture.res, interaction.pendingId, { decision: interaction.decision })
2581
+ else await answerPendingQuestion(capture.res, interaction.pendingId, { answer: interaction.answer })
2582
+ const outcome = await capture.done
2583
+ if (interaction.responseUrl) {
2584
+ const text = slackOutcomeText(interaction.type === 'decide' ? interaction.decision : 'answer', outcome.status, outcome.body, interaction.user)
2585
+ await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
2586
+ }
2587
+ }
2588
+
2440
2589
  const server = createServer(async (req, res) => {
2590
+ const slackHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/slack\/([^/]+)$/)
2591
+ if (slackHookMatch) {
2592
+ try {
2593
+ await handleSlackInteraction(req, res, decodeURIComponent(slackHookMatch[1]))
2594
+ } catch (err) {
2595
+ console.error('[loopengine] Slack interaction failed:', err)
2596
+ if (!res.headersSent) res.writeHead(500).end()
2597
+ }
2598
+ return
2599
+ }
2600
+
2601
+ const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
2602
+ if (decideMatch) {
2603
+ try {
2604
+ await handleDecide(req, res, decideMatch[1])
2605
+ } catch (err) {
2606
+ console.error('[loopengine] decision link failed:', err)
2607
+ if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Something went wrong; try again.' }))
2608
+ }
2609
+ return
2610
+ }
2611
+
2441
2612
  if (!isAuthorized(req)) {
2442
2613
  const headers: Record<string, string> = { 'content-type': 'application/json' }
2443
2614
  if (adminAuth) headers['www-authenticate'] = 'Basic realm="loopengine"'
@@ -2833,6 +3004,11 @@ const server = createServer(async (req, res) => {
2833
3004
  await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
2834
3005
  return
2835
3006
  }
3007
+ const slackChannelsMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-channels$/)
3008
+ if (slackChannelsMatch) {
3009
+ await handleSlackChannels(req, res, decodeURIComponent(slackChannelsMatch[1]))
3010
+ return
3011
+ }
2836
3012
  const pendingListMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/pending$/)
2837
3013
  if (pendingListMatch) {
2838
3014
  await handlePendingGet(res, decodeURIComponent(pendingListMatch[1]))
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.67",
12
+ "loopengine": "^0.1.69",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {