create-loopengine 0.1.52 → 0.1.53
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 +69 -6
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -101,6 +101,10 @@ 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,
|
|
106
110
|
removeAgentNotifier,
|
|
@@ -1045,7 +1049,7 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
|
|
|
1045
1049
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(listDeclaredEnvVars(agentName)))
|
|
1046
1050
|
}
|
|
1047
1051
|
|
|
1048
|
-
// Backs the console's Permissions
|
|
1052
|
+
// Backs the console's Permissions tab (where approval requests and questions are sent) — see
|
|
1049
1053
|
// web/notifier-admin.ts. Reading is open like the rest of the admin
|
|
1050
1054
|
// surface; saving or removing writes a secret and a file, so it gets the
|
|
1051
1055
|
// same refuse-without-auth gate as the env routes below.
|
|
@@ -1055,7 +1059,7 @@ function handleNotifierGet(res: ServerResponse, agentName: string): void {
|
|
|
1055
1059
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1056
1060
|
return
|
|
1057
1061
|
}
|
|
1058
|
-
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeAgentNotifier(entry.config, defaultWebhook)))
|
|
1062
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
|
|
1059
1063
|
}
|
|
1060
1064
|
|
|
1061
1065
|
async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
@@ -1077,7 +1081,7 @@ async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agen
|
|
|
1077
1081
|
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1078
1082
|
return
|
|
1079
1083
|
}
|
|
1080
|
-
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeAgentNotifier(entry.config, defaultWebhook)))
|
|
1084
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
|
|
1081
1085
|
}
|
|
1082
1086
|
|
|
1083
1087
|
function handleNotifierDelete(res: ServerResponse, agentName: string): void {
|
|
@@ -1093,7 +1097,7 @@ function handleNotifierDelete(res: ServerResponse, agentName: string): void {
|
|
|
1093
1097
|
return
|
|
1094
1098
|
}
|
|
1095
1099
|
removeAgentNotifier(entry.config)
|
|
1096
|
-
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 }))
|
|
1097
1101
|
}
|
|
1098
1102
|
|
|
1099
1103
|
async function handleNotifierTest(res: ServerResponse, agentName: string): Promise<void> {
|
|
@@ -2039,7 +2043,11 @@ async function createCheckpointFromPending(
|
|
|
2039
2043
|
* response here is authoritative on its own, not a fallback for some
|
|
2040
2044
|
* other channel. */
|
|
2041
2045
|
async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
|
|
2042
|
-
|
|
2046
|
+
await resolvePendingApproval(res, pendingId, await readJsonBody(req))
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
// The resolve itself, shared with the decision page's POST /decide/<token>.
|
|
2050
|
+
async function resolvePendingApproval(res: ServerResponse, pendingId: string, body: Record<string, unknown>): Promise<void> {
|
|
2043
2051
|
const decision = body.decision === 'approve' || body.decision === 'deny' ? body.decision : undefined
|
|
2044
2052
|
if (!decision) {
|
|
2045
2053
|
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'approve' or 'deny'" }))
|
|
@@ -2151,7 +2159,10 @@ async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerRes
|
|
|
2151
2159
|
* either answered or it isn't. Shares the same "outstanding empty →
|
|
2152
2160
|
* resume, else report remaining count" tail via respondAfterResolution. */
|
|
2153
2161
|
async function handlePendingQuestionAnswer(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
|
|
2154
|
-
|
|
2162
|
+
await answerPendingQuestion(res, pendingId, await readJsonBody(req))
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
async function answerPendingQuestion(res: ServerResponse, pendingId: string, body: Record<string, unknown>): Promise<void> {
|
|
2155
2166
|
if (typeof body.answer !== 'string') {
|
|
2156
2167
|
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "'answer' must be a string" }))
|
|
2157
2168
|
return
|
|
@@ -2437,7 +2448,59 @@ function handleActivityGet(res: ServerResponse): void {
|
|
|
2437
2448
|
)
|
|
2438
2449
|
}
|
|
2439
2450
|
|
|
2451
|
+
// A decision link (loopengine's core/approval-links.ts): GET shows the decision page,
|
|
2452
|
+
// POST makes the decision. The signed token is the credential, so these
|
|
2453
|
+
// sit outside the admin login on purpose — the link is meant for
|
|
2454
|
+
// whoever gets the notification, not only admins.
|
|
2455
|
+
async function handleDecide(req: IncomingMessage, res: ServerResponse, rawToken: string): Promise<void> {
|
|
2456
|
+
const html = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }
|
|
2457
|
+
const json = { 'content-type': 'application/json', 'cache-control': 'no-store' }
|
|
2458
|
+
const token = verifyDecisionToken(rawToken)
|
|
2459
|
+
if (!token) {
|
|
2460
|
+
if (req.method === 'GET') res.writeHead(404, html).end(renderDecideInvalidPage())
|
|
2461
|
+
else res.writeHead(400, json).end(JSON.stringify({ error: 'This link is not valid or has expired.' }))
|
|
2462
|
+
return
|
|
2463
|
+
}
|
|
2464
|
+
const checkpoint = await checkpoints.get(token.pendingId)
|
|
2465
|
+
const item = checkpoint?.outstanding[token.pendingId]
|
|
2466
|
+
if (!checkpoint || !item || (item.kind ?? 'approval') !== token.kind) {
|
|
2467
|
+
if (req.method === 'GET') res.writeHead(410, html).end(renderDecideGonePage())
|
|
2468
|
+
else res.writeHead(410, json).end(JSON.stringify({ error: 'This decision has already been made.' }))
|
|
2469
|
+
return
|
|
2470
|
+
}
|
|
2471
|
+
if (req.method === 'GET') {
|
|
2472
|
+
res.writeHead(200, html).end(
|
|
2473
|
+
renderDecidePage({
|
|
2474
|
+
kind: token.kind,
|
|
2475
|
+
agent: checkpoint.agent,
|
|
2476
|
+
tool: item.tool,
|
|
2477
|
+
args: item.args,
|
|
2478
|
+
reason: item.reason,
|
|
2479
|
+
sessionId: checkpoint.sessionId,
|
|
2480
|
+
tenant: checkpoint.tenant,
|
|
2481
|
+
createdAt: checkpoint.createdAt,
|
|
2482
|
+
expiresAt: token.expiresAt,
|
|
2483
|
+
}),
|
|
2484
|
+
)
|
|
2485
|
+
return
|
|
2486
|
+
}
|
|
2487
|
+
const body = await readJsonBody(req)
|
|
2488
|
+
if (token.kind === 'question') await answerPendingQuestion(res, token.pendingId, { answer: body.answer })
|
|
2489
|
+
else await resolvePendingApproval(res, token.pendingId, { decision: body.decision, editedArgs: body.editedArgs })
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2440
2492
|
const server = createServer(async (req, res) => {
|
|
2493
|
+
const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
|
|
2494
|
+
if (decideMatch) {
|
|
2495
|
+
try {
|
|
2496
|
+
await handleDecide(req, res, decideMatch[1])
|
|
2497
|
+
} catch (err) {
|
|
2498
|
+
console.error('[loopengine] decision link failed:', err)
|
|
2499
|
+
if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Something went wrong; try again.' }))
|
|
2500
|
+
}
|
|
2501
|
+
return
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2441
2504
|
if (!isAuthorized(req)) {
|
|
2442
2505
|
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
2443
2506
|
if (adminAuth) headers['www-authenticate'] = 'Basic realm="loopengine"'
|