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 +1 -1
- package/template/adapters/http.ts +115 -60
package/package.json
CHANGED
|
@@ -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
|
|
2057
|
-
//
|
|
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
|
|
2061
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
2064
|
-
//
|
|
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="/
|
|
2072
|
-
// extra wiring; anything without those credentials gets the
|
|
2073
|
-
// every other route already gives it.
|
|
2074
|
-
async function
|
|
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: '
|
|
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
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
|
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: `
|
|
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 === '/
|
|
2590
|
-
await
|
|
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
|
|