create-loopengine 0.1.44 → 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.44",
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'
@@ -137,6 +138,7 @@ import {
137
138
  import { SkillGarden } from 'loopengine'
138
139
  import type { Decision, PendingApproval } from 'actauth'
139
140
  import type { LoopEvent } from 'loopengine'
141
+ import { storageSigners } from 'loopengine'
140
142
 
141
143
  const sessions = createSessionStore()
142
144
  const checkpoints = createCheckpointStore()
@@ -2053,91 +2055,139 @@ async function respondAfterResolution(res: ServerResponse, finalCheckpoint: Turn
2053
2055
  // long as the click that requested it takes to resolve, not however
2054
2056
  // long a chat reply might sit around unopened).
2055
2057
  //
2056
- // Deliberately generic, not tied to any one ability — any ability using
2057
- // GCS storage can point a tool's own returned URL here instead of
2058
- // signing itself, as long as it authenticates the same way
2058
+ // Deliberately generic on two axes, not tied to any one ability or
2059
+ // cloud provider: any ability can point a tool's own returned URL here
2060
+ // instead of signing itself, as long as it authenticates the same way
2061
+ // loopengine's own storageSigners.gcs already does
2062
+ // (GOOGLE_APPLICATION_CREDENTIALS_JSON, falling back to Application
2063
+ // Default Credentials) — that's the same logic
2059
2064
  // lp-product-ad-images'/lp-file-archiver's own buildGcsStorageClient
2060
- // already do (GOOGLE_APPLICATION_CREDENTIALS_JSON, falling back to
2061
- // Application Default Credentials) — this is that same logic,
2062
- // centralized, rather than every GCS-backed ability duplicating it
2063
- // (which they already do today for the *upload* side; this just does
2064
- // the same for signing).
2065
+ // already duplicate for the *upload* side, centralized in loopengine
2066
+ // core for signing instead. `provider` picks which one of
2067
+ // loopengine's own storageSigners actually does the signing (only
2068
+ // `gcs` exists today) — adding S3/Azure/etc. later is a loopengine
2069
+ // core change, not something this scaffolded file needs to know about.
2065
2070
  //
2066
2071
  // Sits behind the same isAuthorized() Basic Auth gate every other route
2067
2072
  // on this server already requires (checked once, unconditionally,
2068
2073
  // before any routing below even runs) — not a separately public
2069
2074
  // endpoint. A browser that already loaded /playground has that same
2070
2075
  // origin's Basic Auth credentials cached, so a plain
2071
- // <img src="/gcs-redirect?..."> or download link both just work with no
2072
- // extra wiring; anything without those credentials gets the same 401
2073
- // every other route already gives it.
2074
- async function handleGcsRedirect(req: IncomingMessage, res: ServerResponse): Promise<void> {
2076
+ // <img src="/storage-redirect?..."> or download link both just work
2077
+ // with no extra wiring; anything without those credentials gets the
2078
+ // same 401 every other route already gives it.
2079
+ async function handleStorageRedirect(req: IncomingMessage, res: ServerResponse): Promise<void> {
2075
2080
  const url = new URL(req.url ?? '/', 'http://localhost')
2081
+ const provider = url.searchParams.get('provider')
2076
2082
  const bucket = url.searchParams.get('bucket')
2077
2083
  const object = url.searchParams.get('object')
2078
2084
  const disposition = url.searchParams.get('disposition')
2079
2085
  const filename = url.searchParams.get('filename')
2080
2086
 
2081
- if (!bucket || !object) {
2082
- res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'gcs-redirect requires both bucket and object query params' }))
2087
+ if (!provider || !bucket || !object) {
2088
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'storage-redirect requires provider, bucket, and object query params' }))
2083
2089
  return
2084
2090
  }
2085
2091
  if (disposition && disposition !== 'inline' && disposition !== 'attachment') {
2086
2092
  res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `disposition must be "inline" or "attachment" — got "${disposition}"` }))
2087
2093
  return
2088
2094
  }
2089
-
2090
- // Imported by a variable, not a string literal, so tsc treats this as
2091
- // Promise<any> instead of trying to resolve @google-cloud/storage's
2092
- // own types at compile time — same reasoning
2093
- // lp-product-ad-images'/lp-file-archiver's own saveImage/saveArchive
2094
- // already use, for the same reason: installing it is only required at
2095
- // runtime for a deployment that actually uses this route, not every
2096
- // scaffolded project.
2097
- const gcsModuleName = '@google-cloud/storage'
2098
- let gcs: any
2099
- try {
2100
- gcs = await import(gcsModuleName)
2101
- } catch {
2102
- res.writeHead(500, { 'content-type': 'application/json' }).end(
2103
- JSON.stringify({ error: 'gcs-redirect requires the @google-cloud/storage package — npm install @google-cloud/storage in your own project.' }),
2095
+ const signer = storageSigners[provider]
2096
+ if (!signer) {
2097
+ res.writeHead(400, { 'content-type': 'application/json' }).end(
2098
+ JSON.stringify({ error: `storage-redirect: unknown provider "${provider}" — supported: ${Object.keys(storageSigners).join(', ')}` }),
2104
2099
  )
2105
2100
  return
2106
2101
  }
2107
2102
 
2108
- let client: any
2109
- const credentialsJson = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON
2110
- if (credentialsJson) {
2111
- let credentials: { project_id?: string }
2112
- try {
2113
- credentials = JSON.parse(credentialsJson)
2114
- } catch {
2115
- res.writeHead(500, { 'content-type': 'application/json' }).end(
2116
- JSON.stringify({
2117
- error: 'GOOGLE_APPLICATION_CREDENTIALS_JSON is not valid JSON — paste the entire contents of the downloaded service-account key file, unedited.',
2118
- }),
2119
- )
2120
- return
2121
- }
2122
- client = new gcs.Storage({ credentials, projectId: credentials.project_id })
2123
- } else {
2124
- client = new gcs.Storage()
2125
- }
2126
-
2127
2103
  try {
2128
- const file = client.bucket(bucket).file(object)
2129
- const expires = Date.now() + 5 * 60 * 1000
2130
- const signOptions: Record<string, unknown> = { action: 'read', expires }
2131
- if (disposition === 'attachment') {
2132
- signOptions.responseDisposition = `attachment; filename="${filename || object.split('/').pop()}"`
2133
- }
2134
- const [signedUrl] = await file.getSignedUrl(signOptions)
2104
+ const signedUrl = await signer({ bucket, object, disposition: (disposition as 'inline' | 'attachment' | null) ?? undefined, filename: filename ?? undefined })
2135
2105
  res.writeHead(302, { location: signedUrl }).end()
2136
2106
  } catch (err) {
2137
2107
  res
2138
2108
  .writeHead(502, { 'content-type': 'application/json' })
2139
- .end(JSON.stringify({ error: `gcs-redirect: could not sign a URL for gs://${bucket}/${object}: ${err instanceof Error ? err.message : String(err)}` }))
2109
+ .end(JSON.stringify({ error: `storage-redirect: could not sign a URL for ${provider}://${bucket}/${object}: ${err instanceof Error ? err.message : String(err)}` }))
2110
+ }
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
2140
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)
2141
2191
  }
2142
2192
 
2143
2193
  const server = createServer(async (req, res) => {
@@ -2586,8 +2636,13 @@ const server = createServer(async (req, res) => {
2586
2636
  return
2587
2637
  }
2588
2638
 
2589
- if (req.method === 'GET' && pathname === '/gcs-redirect') {
2590
- await handleGcsRedirect(req, res)
2639
+ if (req.method === 'GET' && pathname === '/storage-redirect') {
2640
+ await handleStorageRedirect(req, res)
2641
+ return
2642
+ }
2643
+
2644
+ if (req.method === 'GET' && pathname === '/local-file') {
2645
+ await handleLocalFile(req, res)
2591
2646
  return
2592
2647
  }
2593
2648