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 +1 -1
- package/template/adapters/http.ts +118 -2
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'
|
|
@@ -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' }))
|