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 +1 -1
- package/template/adapters/http.ts +182 -6
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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]))
|