create-loopengine 0.1.49 → 0.1.50
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 +76 -15
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -98,6 +98,8 @@ import {
|
|
|
98
98
|
unsetAgentEnvVar,
|
|
99
99
|
createAgentEnv,
|
|
100
100
|
agentDirFor,
|
|
101
|
+
verifyProxyIdentity,
|
|
102
|
+
TRUSTED_PROXY_IDENTITY_HEADER,
|
|
101
103
|
EnvVarNameError,
|
|
102
104
|
readActauthConfig,
|
|
103
105
|
addActauthRule,
|
|
@@ -1045,7 +1047,7 @@ function envSlotOf(req: IncomingMessage): 'shared' | 'agent' | undefined {
|
|
|
1045
1047
|
// Admin UI" — LOOPENGINE_ADMIN_AUTH being optional today is an
|
|
1046
1048
|
// acceptable gap for config, not for secrets).
|
|
1047
1049
|
async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName: string, varName: string): Promise<void> {
|
|
1048
|
-
if (!
|
|
1050
|
+
if (!secretsProtected) {
|
|
1049
1051
|
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1050
1052
|
JSON.stringify({ error: 'Refusing to set an env var without LOOPENGINE_ADMIN_AUTH configured — set it before managing secrets through this route.' }),
|
|
1051
1053
|
)
|
|
@@ -1087,8 +1089,8 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
|
|
|
1087
1089
|
return
|
|
1088
1090
|
}
|
|
1089
1091
|
try {
|
|
1090
|
-
if (slot === 'agent') setAgentEnvVar(agentName, varName, body.value)
|
|
1091
|
-
else setEnvVar(varName, body.value)
|
|
1092
|
+
if (slot === 'agent') await setAgentEnvVar(agentName, varName, body.value)
|
|
1093
|
+
else await setEnvVar(varName, body.value)
|
|
1092
1094
|
} catch (err) {
|
|
1093
1095
|
const status = err instanceof EnvVarNameError ? 400 : 500
|
|
1094
1096
|
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
@@ -1102,7 +1104,7 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
|
|
|
1102
1104
|
// not just config, so this refuses outright rather than proceeding
|
|
1103
1105
|
// open when LOOPENGINE_ADMIN_AUTH isn't configured at all.
|
|
1104
1106
|
async function handleEnvDelete(req: IncomingMessage, res: ServerResponse, agentName: string, varName: string): Promise<void> {
|
|
1105
|
-
if (!
|
|
1107
|
+
if (!secretsProtected) {
|
|
1106
1108
|
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1107
1109
|
JSON.stringify({ error: 'Refusing to unset an env var without LOOPENGINE_ADMIN_AUTH configured — set it before managing secrets through this route.' }),
|
|
1108
1110
|
)
|
|
@@ -1118,8 +1120,8 @@ async function handleEnvDelete(req: IncomingMessage, res: ServerResponse, agentN
|
|
|
1118
1120
|
return
|
|
1119
1121
|
}
|
|
1120
1122
|
try {
|
|
1121
|
-
if (slot === 'agent') unsetAgentEnvVar(agentName, varName)
|
|
1122
|
-
else unsetEnvVar(varName)
|
|
1123
|
+
if (slot === 'agent') await unsetAgentEnvVar(agentName, varName)
|
|
1124
|
+
else await unsetEnvVar(varName)
|
|
1123
1125
|
} catch (err) {
|
|
1124
1126
|
const status = err instanceof EnvVarNameError ? 400 : 500
|
|
1125
1127
|
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
@@ -1202,7 +1204,7 @@ async function handleAbilitiesSearchGet(res: ServerResponse, agentName: string,
|
|
|
1202
1204
|
// LOOPENGINE_ADMIN_AUTH isn't configured at all, rather than proceeding
|
|
1203
1205
|
// open like most routes here do.
|
|
1204
1206
|
async function handleAbilityInstallPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1205
|
-
if (!
|
|
1207
|
+
if (!secretsProtected) {
|
|
1206
1208
|
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1207
1209
|
JSON.stringify({ error: 'Refusing to install an ability without LOOPENGINE_ADMIN_AUTH configured — set it before installing abilities through this route.' }),
|
|
1208
1210
|
)
|
|
@@ -1237,7 +1239,7 @@ async function handleAbilityInstallPost(req: IncomingMessage, res: ServerRespons
|
|
|
1237
1239
|
// third-party code inside the agent's project the next time it's
|
|
1238
1240
|
// loaded, same as install does.
|
|
1239
1241
|
async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
|
|
1240
|
-
if (!
|
|
1242
|
+
if (!secretsProtected) {
|
|
1241
1243
|
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1242
1244
|
JSON.stringify({ error: 'Refusing to upgrade an ability without LOOPENGINE_ADMIN_AUTH configured — set it before upgrading abilities through this route.' }),
|
|
1243
1245
|
)
|
|
@@ -1272,7 +1274,7 @@ async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerRespons
|
|
|
1272
1274
|
// behavior, is a normal partial outcome the Admin UI shows and offers
|
|
1273
1275
|
// to retry with force, not a failure).
|
|
1274
1276
|
async function handleAbilityUninstallDelete(res: ServerResponse, agentName: string, abilityName: string, force: boolean): Promise<void> {
|
|
1275
|
-
if (!
|
|
1277
|
+
if (!secretsProtected) {
|
|
1276
1278
|
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1277
1279
|
JSON.stringify({ error: 'Refusing to uninstall an ability without LOOPENGINE_ADMIN_AUTH configured — set it before uninstalling abilities through this route.' }),
|
|
1278
1280
|
)
|
|
@@ -1314,7 +1316,7 @@ const NPM_INSTALL_DEPS_TIMEOUT_MS = 180_000
|
|
|
1314
1316
|
// package.json happened to declare, not something to trust for shell
|
|
1315
1317
|
// interpolation.
|
|
1316
1318
|
async function handleAbilityInstallDepsPost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
|
|
1317
|
-
if (!
|
|
1319
|
+
if (!secretsProtected) {
|
|
1318
1320
|
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1319
1321
|
JSON.stringify({ error: 'Refusing to run npm install without LOOPENGINE_ADMIN_AUTH configured — set it before installing dependencies through this route.' }),
|
|
1320
1322
|
)
|
|
@@ -1728,6 +1730,8 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
|
|
|
1728
1730
|
}
|
|
1729
1731
|
const { entry, message, rawSessionId, storageSessionId, tenant } = parsed.value
|
|
1730
1732
|
|
|
1733
|
+
openStreams++
|
|
1734
|
+
res.on('close', () => openStreams--)
|
|
1731
1735
|
res.writeHead(200, {
|
|
1732
1736
|
'content-type': 'text/event-stream',
|
|
1733
1737
|
'cache-control': 'no-cache',
|
|
@@ -1831,7 +1835,15 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
|
|
|
1831
1835
|
// default. A real deployment opts in by setting it; either way, a
|
|
1832
1836
|
// startup warning makes "I forgot to set this" loud instead of silent.
|
|
1833
1837
|
const adminAuth = process.env.LOOPENGINE_ADMIN_AUTH
|
|
1834
|
-
|
|
1838
|
+
// Hosted mode (see loopengine's core/trusted-proxy.ts): every request
|
|
1839
|
+
// must carry an X-LoopEngine-Identity header signed with this secret by
|
|
1840
|
+
// the router in front of this server. Unset (the default, and every
|
|
1841
|
+
// self-hosted install) means this file behaves exactly as it always has.
|
|
1842
|
+
const trustedProxySecret = process.env.LOOPENGINE_TRUSTED_PROXY_SECRET
|
|
1843
|
+
// Routes that write secrets or install code refuse to run on a server
|
|
1844
|
+
// with no auth at all — either form of auth counts.
|
|
1845
|
+
const secretsProtected = Boolean(adminAuth || trustedProxySecret)
|
|
1846
|
+
if (!adminAuth && !trustedProxySecret) {
|
|
1835
1847
|
console.warn(
|
|
1836
1848
|
'[loopengine] LOOPENGINE_ADMIN_AUTH is not set — every route on this server (including tool-call approvals, conversation history, and permission rules) is open to anyone who can reach it. Set LOOPENGINE_ADMIN_AUTH="user:pass" to require HTTP Basic Auth.',
|
|
1837
1849
|
)
|
|
@@ -1844,7 +1856,12 @@ if (!adminAuth) {
|
|
|
1844
1856
|
// first; a length mismatch isn't sensitive information worth spending a
|
|
1845
1857
|
// constant-time comparison to protect.
|
|
1846
1858
|
function isAuthorized(req: IncomingMessage): boolean {
|
|
1847
|
-
if (
|
|
1859
|
+
if (trustedProxySecret && verifyProxyIdentity(req.headers[TRUSTED_PROXY_IDENTITY_HEADER], trustedProxySecret)) return true
|
|
1860
|
+
// Basic Auth still works alongside trusted-proxy mode (direct operator
|
|
1861
|
+
// access to one workspace) — but with neither form of credentials, a
|
|
1862
|
+
// trusted-proxy server is closed, never open the way a server with no
|
|
1863
|
+
// auth configured at all is.
|
|
1864
|
+
if (!adminAuth) return !trustedProxySecret
|
|
1848
1865
|
const header = req.headers.authorization
|
|
1849
1866
|
if (!header || !header.startsWith('Basic ')) return false
|
|
1850
1867
|
const provided = Buffer.from(header.slice('Basic '.length), 'base64')
|
|
@@ -2284,11 +2301,43 @@ async function handleLocalFile(req: IncomingMessage, res: ServerResponse): Promi
|
|
|
2284
2301
|
createReadStream(resolved).pipe(res)
|
|
2285
2302
|
}
|
|
2286
2303
|
|
|
2304
|
+
// Opt-in (LOOPENGINE_EXIT_ON_CODE_CHANGE=1): Node caches an imported
|
|
2305
|
+
// module, so a tool file an ability install/upgrade/uninstall just wrote
|
|
2306
|
+
// isn't picked up until the process restarts. Under a supervisor that
|
|
2307
|
+
// restarts it (a hosting platform, Docker's restart policy, systemd),
|
|
2308
|
+
// exiting cleanly right after the change is the simplest way to apply
|
|
2309
|
+
// it. Off by default — a plain `loopengine serve` has nothing to restart
|
|
2310
|
+
// it, so exiting there would just take the server down.
|
|
2311
|
+
const exitOnCodeChange = process.env.LOOPENGINE_EXIT_ON_CODE_CHANGE === '1'
|
|
2312
|
+
function exitIfCodeChanged(res: ServerResponse): void {
|
|
2313
|
+
if (!exitOnCodeChange || res.statusCode >= 300) return
|
|
2314
|
+
console.log('[loopengine] ability code changed — exiting so the supervisor restarts this server with it loaded')
|
|
2315
|
+
// shutdown() drains in-flight requests (this one's response included)
|
|
2316
|
+
// before exiting.
|
|
2317
|
+
setImmediate(() => void shutdown())
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// Backs GET /internal/activity (trusted-proxy mode only) — what a host
|
|
2321
|
+
// checks before stopping an idle server, so it never stops one mid-turn.
|
|
2322
|
+
let openStreams = 0
|
|
2323
|
+
let lastRequestAt = Date.now()
|
|
2324
|
+
|
|
2325
|
+
function handleActivityGet(res: ServerResponse): void {
|
|
2326
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
2327
|
+
JSON.stringify({
|
|
2328
|
+
inFlightTurns: sessionTurns.size,
|
|
2329
|
+
openStreams,
|
|
2330
|
+
lastRequestAt: new Date(lastRequestAt).toISOString(),
|
|
2331
|
+
idleSeconds: Math.floor((Date.now() - lastRequestAt) / 1000),
|
|
2332
|
+
}),
|
|
2333
|
+
)
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2287
2336
|
const server = createServer(async (req, res) => {
|
|
2288
2337
|
if (!isAuthorized(req)) {
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2338
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
2339
|
+
if (adminAuth) headers['www-authenticate'] = 'Basic realm="loopengine"'
|
|
2340
|
+
res.writeHead(401, headers).end(JSON.stringify({ error: 'authorization required' }))
|
|
2292
2341
|
return
|
|
2293
2342
|
}
|
|
2294
2343
|
|
|
@@ -2308,6 +2357,14 @@ const server = createServer(async (req, res) => {
|
|
|
2308
2357
|
// route individually.
|
|
2309
2358
|
const pathname = (req.url ?? '/').split('?')[0]
|
|
2310
2359
|
|
|
2360
|
+
// Only exists in trusted-proxy mode, and polling it doesn't count as
|
|
2361
|
+
// activity itself.
|
|
2362
|
+
if (trustedProxySecret && req.method === 'GET' && pathname === '/internal/activity') {
|
|
2363
|
+
handleActivityGet(res)
|
|
2364
|
+
return
|
|
2365
|
+
}
|
|
2366
|
+
lastRequestAt = Date.now()
|
|
2367
|
+
|
|
2311
2368
|
const streamMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages\/stream$/)
|
|
2312
2369
|
if (streamMatch) {
|
|
2313
2370
|
await handleMessagesStream(req, res, decodeURIComponent(streamMatch[1]))
|
|
@@ -2685,17 +2742,20 @@ const server = createServer(async (req, res) => {
|
|
|
2685
2742
|
const abilityUpgradeMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/upgrade$/)
|
|
2686
2743
|
if (abilityUpgradeMatch && req.method === 'POST') {
|
|
2687
2744
|
await handleAbilityUpgradePost(req, res, decodeURIComponent(abilityUpgradeMatch[1]), decodeURIComponent(abilityUpgradeMatch[2]))
|
|
2745
|
+
exitIfCodeChanged(res)
|
|
2688
2746
|
return
|
|
2689
2747
|
}
|
|
2690
2748
|
const abilityInstallDepsMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/install-deps$/)
|
|
2691
2749
|
if (abilityInstallDepsMatch && req.method === 'POST') {
|
|
2692
2750
|
await handleAbilityInstallDepsPost(req, res, decodeURIComponent(abilityInstallDepsMatch[1]), decodeURIComponent(abilityInstallDepsMatch[2]))
|
|
2751
|
+
exitIfCodeChanged(res)
|
|
2693
2752
|
return
|
|
2694
2753
|
}
|
|
2695
2754
|
const abilityMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)$/)
|
|
2696
2755
|
if (abilityMatch && req.method === 'DELETE') {
|
|
2697
2756
|
const force = new URL(req.url ?? '/', 'http://localhost').searchParams.get('force') === 'true'
|
|
2698
2757
|
await handleAbilityUninstallDelete(res, decodeURIComponent(abilityMatch[1]), decodeURIComponent(abilityMatch[2]), force)
|
|
2758
|
+
exitIfCodeChanged(res)
|
|
2699
2759
|
return
|
|
2700
2760
|
}
|
|
2701
2761
|
const abilitiesMatch = pathname.match(/^\/agents\/([^/]+)\/abilities$/)
|
|
@@ -2705,6 +2765,7 @@ const server = createServer(async (req, res) => {
|
|
|
2705
2765
|
}
|
|
2706
2766
|
if (abilitiesMatch && req.method === 'POST') {
|
|
2707
2767
|
await handleAbilityInstallPost(req, res, decodeURIComponent(abilitiesMatch[1]))
|
|
2768
|
+
exitIfCodeChanged(res)
|
|
2708
2769
|
return
|
|
2709
2770
|
}
|
|
2710
2771
|
|