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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
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,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 → Approvals section — see
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
- const body = await readJsonBody(req)
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
- const body = await readJsonBody(req)
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"'
@@ -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.68",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {