create-loopengine 0.1.45 → 0.1.47

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.45",
3
+ "version": "0.1.47",
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",
@@ -29,9 +29,10 @@
29
29
  // file's call — see AgentConfig.sessionIdFor and defaultSessionIdFor below.
30
30
  import { execFile } from 'node:child_process'
31
31
  import { randomUUID, timingSafeEqual } from 'node:crypto'
32
- import { existsSync } from 'node:fs'
32
+ import { createReadStream, existsSync } from 'node:fs'
33
+ import { stat } from 'node:fs/promises'
33
34
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
34
- import { join } from 'node:path'
35
+ import { extname, join, resolve as resolvePath, sep } from 'node:path'
35
36
  import { pathToFileURL } from 'node:url'
36
37
  import { promisify } from 'node:util'
37
38
  import { getEntry, listAgents, projectDir, registerAgent, updateAgent, type RegistryEntry } from '../agent-registry.js'
@@ -92,6 +93,7 @@ import {
92
93
  SkillNotFoundError,
93
94
  listDeclaredEnvVars,
94
95
  setEnvVar,
96
+ unsetEnvVar,
95
97
  EnvVarNameError,
96
98
  readActauthConfig,
97
99
  addActauthRule,
@@ -1066,6 +1068,31 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
1066
1068
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
1067
1069
  }
1068
1070
 
1071
+ // Same auth requirement as handleEnvPut above and the same reasoning —
1072
+ // removing a secret from .env is still a secrets-management action,
1073
+ // not just config, so this refuses outright rather than proceeding
1074
+ // open when LOOPENGINE_ADMIN_AUTH isn't configured at all.
1075
+ async function handleEnvDelete(res: ServerResponse, agentName: string, varName: string): Promise<void> {
1076
+ if (!adminAuth) {
1077
+ res.writeHead(403, { 'content-type': 'application/json' }).end(
1078
+ JSON.stringify({ error: 'Refusing to unset an env var without LOOPENGINE_ADMIN_AUTH configured — set it before managing secrets through this route.' }),
1079
+ )
1080
+ return
1081
+ }
1082
+ if (!getEntry(agentName)) {
1083
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1084
+ return
1085
+ }
1086
+ try {
1087
+ unsetEnvVar(varName)
1088
+ } catch (err) {
1089
+ const status = err instanceof EnvVarNameError ? 400 : 500
1090
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1091
+ return
1092
+ }
1093
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
1094
+ }
1095
+
1069
1096
  // A cheap, good-enough presence check — existsSync(node_modules/<dep>)
1070
1097
  // — rather than an actual module resolution (require.resolve/
1071
1098
  // import.meta.resolve): this only ever decides whether to show an
@@ -2109,6 +2136,86 @@ async function handleStorageRedirect(req: IncomingMessage, res: ServerResponse):
2109
2136
  }
2110
2137
  }
2111
2138
 
2139
+ const LOCAL_FILE_CONTENT_TYPES: Record<string, string> = {
2140
+ '.png': 'image/png',
2141
+ '.jpg': 'image/jpeg',
2142
+ '.jpeg': 'image/jpeg',
2143
+ '.webp': 'image/webp',
2144
+ '.gif': 'image/gif',
2145
+ '.zip': 'application/zip',
2146
+ '.pdf': 'application/pdf',
2147
+ }
2148
+
2149
+ // storage-redirect above only ever has a URL to redirect to *because*
2150
+ // the cloud provider already serves the bytes itself — AD_IMAGE_STORAGE=
2151
+ // local/ARCHIVE_STORAGE=local has no such external server at all, so
2152
+ // this route serves the bytes directly instead of redirecting anywhere,
2153
+ // giving local storage the same preview/download-button treatment
2154
+ // storage-redirect already gives gcs, instead of the bare filesystem
2155
+ // path a local ability result used to be stuck with (unopenable by
2156
+ // anything except direct server access).
2157
+ //
2158
+ // `path` is resolved against process.cwd() (this process's own working
2159
+ // directory — not this file's location, and not any one ability's own
2160
+ // AD_IMAGE_OUTPUT_DIR/ARCHIVE_OUTPUT_DIR specifically, since this route
2161
+ // has no idea which ability or env var produced the value it's given)
2162
+ // and then checked against that same root before anything is read —
2163
+ // this route is otherwise a generic "serve a file from this
2164
+ // deployment's own project directory," and skipping that check would
2165
+ // turn it into an arbitrary-file-read over HTTP. AD_IMAGE_OUTPUT_DIR/
2166
+ // ARCHIVE_OUTPUT_DIR left at their own defaults (a relative path) are
2167
+ // already under cwd, so this covers the common case with zero
2168
+ // configuration; a deployment pointing either at an absolute path
2169
+ // outside the project directory gets no preview/download URL for that
2170
+ // file at all — see generate_google_ad_images.ts's/create_zip_archive.ts's
2171
+ // own saveImage/saveArchive doc comments for that fallback.
2172
+ //
2173
+ // Same Basic Auth gate as every other route on this server — see
2174
+ // handleStorageRedirect's own doc comment for why that's what makes a
2175
+ // plain <img src="/local-file?..."> just work with no extra wiring.
2176
+ async function handleLocalFile(req: IncomingMessage, res: ServerResponse): Promise<void> {
2177
+ const url = new URL(req.url ?? '/', 'http://localhost')
2178
+ const relativePath = url.searchParams.get('path')
2179
+ const disposition = url.searchParams.get('disposition')
2180
+ const filename = url.searchParams.get('filename')
2181
+
2182
+ if (!relativePath) {
2183
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'local-file requires a path query param' }))
2184
+ return
2185
+ }
2186
+ if (disposition && disposition !== 'inline' && disposition !== 'attachment') {
2187
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `disposition must be "inline" or "attachment" — got "${disposition}"` }))
2188
+ return
2189
+ }
2190
+
2191
+ const root = process.cwd()
2192
+ const resolved = resolvePath(root, relativePath)
2193
+ if (resolved !== root && !resolved.startsWith(root + sep)) {
2194
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'local-file: path escapes this deployment\'s own project directory' }))
2195
+ return
2196
+ }
2197
+
2198
+ let stats: Awaited<ReturnType<typeof stat>>
2199
+ try {
2200
+ stats = await stat(resolved)
2201
+ } catch {
2202
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `local-file: no such file "${relativePath}"` }))
2203
+ return
2204
+ }
2205
+ if (!stats.isFile()) {
2206
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `local-file: "${relativePath}" is not a regular file` }))
2207
+ return
2208
+ }
2209
+
2210
+ const contentType = LOCAL_FILE_CONTENT_TYPES[extname(resolved).toLowerCase()] ?? 'application/octet-stream'
2211
+ const headers: Record<string, string> = { 'content-type': contentType, 'content-length': String(stats.size) }
2212
+ if (disposition === 'attachment') {
2213
+ headers['content-disposition'] = `attachment; filename="${filename || relativePath.split('/').pop()}"`
2214
+ }
2215
+ res.writeHead(200, headers)
2216
+ createReadStream(resolved).pipe(res)
2217
+ }
2218
+
2112
2219
  const server = createServer(async (req, res) => {
2113
2220
  if (!isAuthorized(req)) {
2114
2221
  res
@@ -2489,6 +2596,10 @@ const server = createServer(async (req, res) => {
2489
2596
  await handleEnvPut(req, res, decodeURIComponent(envVarMatch[1]), decodeURIComponent(envVarMatch[2]))
2490
2597
  return
2491
2598
  }
2599
+ if (envVarMatch && req.method === 'DELETE') {
2600
+ await handleEnvDelete(res, decodeURIComponent(envVarMatch[1]), decodeURIComponent(envVarMatch[2]))
2601
+ return
2602
+ }
2492
2603
 
2493
2604
  // Backs the Admin UI's Abilities tab — see handleAbilitiesGet/
2494
2605
  // handleAbilitiesSearchGet/handleAbilityInstallPost's own doc
@@ -2560,6 +2671,11 @@ const server = createServer(async (req, res) => {
2560
2671
  return
2561
2672
  }
2562
2673
 
2674
+ if (req.method === 'GET' && pathname === '/local-file') {
2675
+ await handleLocalFile(req, res)
2676
+ return
2677
+ }
2678
+
2563
2679
  const match = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages$/)
2564
2680
  if (!match) {
2565
2681
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not found' }))