create-loopengine 0.1.45 → 0.1.46

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.46",
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'
@@ -2109,6 +2110,86 @@ async function handleStorageRedirect(req: IncomingMessage, res: ServerResponse):
2109
2110
  }
2110
2111
  }
2111
2112
 
2113
+ const LOCAL_FILE_CONTENT_TYPES: Record<string, string> = {
2114
+ '.png': 'image/png',
2115
+ '.jpg': 'image/jpeg',
2116
+ '.jpeg': 'image/jpeg',
2117
+ '.webp': 'image/webp',
2118
+ '.gif': 'image/gif',
2119
+ '.zip': 'application/zip',
2120
+ '.pdf': 'application/pdf',
2121
+ }
2122
+
2123
+ // storage-redirect above only ever has a URL to redirect to *because*
2124
+ // the cloud provider already serves the bytes itself — AD_IMAGE_STORAGE=
2125
+ // local/ARCHIVE_STORAGE=local has no such external server at all, so
2126
+ // this route serves the bytes directly instead of redirecting anywhere,
2127
+ // giving local storage the same preview/download-button treatment
2128
+ // storage-redirect already gives gcs, instead of the bare filesystem
2129
+ // path a local ability result used to be stuck with (unopenable by
2130
+ // anything except direct server access).
2131
+ //
2132
+ // `path` is resolved against process.cwd() (this process's own working
2133
+ // directory — not this file's location, and not any one ability's own
2134
+ // AD_IMAGE_OUTPUT_DIR/ARCHIVE_OUTPUT_DIR specifically, since this route
2135
+ // has no idea which ability or env var produced the value it's given)
2136
+ // and then checked against that same root before anything is read —
2137
+ // this route is otherwise a generic "serve a file from this
2138
+ // deployment's own project directory," and skipping that check would
2139
+ // turn it into an arbitrary-file-read over HTTP. AD_IMAGE_OUTPUT_DIR/
2140
+ // ARCHIVE_OUTPUT_DIR left at their own defaults (a relative path) are
2141
+ // already under cwd, so this covers the common case with zero
2142
+ // configuration; a deployment pointing either at an absolute path
2143
+ // outside the project directory gets no preview/download URL for that
2144
+ // file at all — see generate_google_ad_images.ts's/create_zip_archive.ts's
2145
+ // own saveImage/saveArchive doc comments for that fallback.
2146
+ //
2147
+ // Same Basic Auth gate as every other route on this server — see
2148
+ // handleStorageRedirect's own doc comment for why that's what makes a
2149
+ // plain <img src="/local-file?..."> just work with no extra wiring.
2150
+ async function handleLocalFile(req: IncomingMessage, res: ServerResponse): Promise<void> {
2151
+ const url = new URL(req.url ?? '/', 'http://localhost')
2152
+ const relativePath = url.searchParams.get('path')
2153
+ const disposition = url.searchParams.get('disposition')
2154
+ const filename = url.searchParams.get('filename')
2155
+
2156
+ if (!relativePath) {
2157
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'local-file requires a path query param' }))
2158
+ return
2159
+ }
2160
+ if (disposition && disposition !== 'inline' && disposition !== 'attachment') {
2161
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `disposition must be "inline" or "attachment" — got "${disposition}"` }))
2162
+ return
2163
+ }
2164
+
2165
+ const root = process.cwd()
2166
+ const resolved = resolvePath(root, relativePath)
2167
+ if (resolved !== root && !resolved.startsWith(root + sep)) {
2168
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'local-file: path escapes this deployment\'s own project directory' }))
2169
+ return
2170
+ }
2171
+
2172
+ let stats: Awaited<ReturnType<typeof stat>>
2173
+ try {
2174
+ stats = await stat(resolved)
2175
+ } catch {
2176
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `local-file: no such file "${relativePath}"` }))
2177
+ return
2178
+ }
2179
+ if (!stats.isFile()) {
2180
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `local-file: "${relativePath}" is not a regular file` }))
2181
+ return
2182
+ }
2183
+
2184
+ const contentType = LOCAL_FILE_CONTENT_TYPES[extname(resolved).toLowerCase()] ?? 'application/octet-stream'
2185
+ const headers: Record<string, string> = { 'content-type': contentType, 'content-length': String(stats.size) }
2186
+ if (disposition === 'attachment') {
2187
+ headers['content-disposition'] = `attachment; filename="${filename || relativePath.split('/').pop()}"`
2188
+ }
2189
+ res.writeHead(200, headers)
2190
+ createReadStream(resolved).pipe(res)
2191
+ }
2192
+
2112
2193
  const server = createServer(async (req, res) => {
2113
2194
  if (!isAuthorized(req)) {
2114
2195
  res
@@ -2560,6 +2641,11 @@ const server = createServer(async (req, res) => {
2560
2641
  return
2561
2642
  }
2562
2643
 
2644
+ if (req.method === 'GET' && pathname === '/local-file') {
2645
+ await handleLocalFile(req, res)
2646
+ return
2647
+ }
2648
+
2563
2649
  const match = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages$/)
2564
2650
  if (!match) {
2565
2651
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not found' }))